Quadion Technologies

Crafting Great Software

Follow publication

Creating a Workout App: How iOS and watchOS can be best buddies using WCSession and WatchConnectivity

Introduction

Nicolas Vezzali Dussio
Quadion Technologies
15 min readOct 29, 2024

In the Apple ecosystem, communication is key. Your iPhone and Apple Watch are constantly in sync, whether it’s sharing health data, notifications, or even the latest weather updates. But how does this seamless communication happen? Well, it can be established in several ways, among which the most commonly used is WCSession, the framework that makes it all possible. Today, we’ll explore how you can harness this power to create a companion app step-by-step where your iOS and watchOS apps communicate.

How it works?

Behind the scenes, your iPhone and Apple Watch use Bluetooth and Wi-Fi to connect. When you’re within range, Bluetooth Low Energy (BLE) is the primary channel, ensuring a quick and efficient data exchange while conserving battery life. If BLE isn’t available, they seamlessly switch to Wi-Fi, keeping the connection strong even if your iPhone is across the house. This dance between Bluetooth and Wi-Fi, orchestrated by WCSession, ensures that data travels swiftly and securely between devices. It’s a fascinating blend of hardware and software working together to create a seamless user experience, and today, we’re going to break down how you can tap into this technology to make your apps communicate like pros.

In this tutorial, we’ll build a simple Workout app, a common use case for watchOS users. We’ll focus on the basics, such as managing a list of exercises with repetitions and weights, and demonstrate how these features integrate between iOS and watchOS.

Let’s get started!

Setting Up the Project

To begin, create a new project in Xcode under the watchOS tab:

Set the app name and now the most important thing; select the “Watch App with New Companion iOS App option and finish the project creation.

Now we need to add the “WatchConnectivity.framework” to both project targets under the “Framework, Libraries, and Embedded Content” section:

IOS target:

WatchOS target:

Once done, we are ready for the next step: CODING!

The first thing that we can do is create a new folder in the root of our App: We will call it Shared.

So our App is ready to be coded, but first, let me explain the basics of our project architecture:

1) The Shared folder will be used to locate everything we need to share on both iOS and watchOS scopes, such as models, managers, configurations, assets, protocols and everything else you want to use on both apps.

2) The companion-app folder contains our iOS app.

3) The companion-app Watch App folder is our watchOS App.

Thinking on our app “brain”: The Managers

To start coding, we need to think about our Manager files where we going to centralize all our I/O messages from iOS to watchOS and another one for watchOS to iOS; So, we are going to split this overall functionality into three files:

  • WorkoutManagerProtocol: our main protocol where we will define essential properties and methods to re-use on both targets.
  • IOSManager: this is the manager for iOS scope. It will be responsible for communicating with WatchOSManager.
  • WatchOSManager: this is the manager for the watchOS scope. It will be responsible for communicating with IOSManager.

This is a basic diagram witch represents our initial app architecture:

Ok, stop talking and let’s start coding, please!

Go ahead with the first manager; IOSManager. Create it inside the companion-app folder (iOS App) and make sure that the only selected target is the companion-app one:

Now create the class and extend NSObject, ObservableObject, and most importantly: WDSessionDelegate. Now, let XCode auto-complete the stubs for conformance for us:

At the moment we have the minimum required methods by the WCSessionDelegate, but we need some extra things to add:

Add the WCSession property and initialize it in the Init() method to handle the connectivity with iOS. It was configured to try to connect when the IOSManager is created:

import Foundation
import WatchConnectivity

class IOSManager: NSObject, ObservableObject, WCSessionDelegate {
var session: WCSession

override init() {
self.session = .default
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
} else {
print("iOS to watchOS connection not supported")
}
}

func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error = error {
print("iOS: Error on session activation: \(error.localizedDescription)")
return
}
print("iOS session activation complete: \(activationState.rawValue)")
}

func sessionDidBecomeInactive(_ session: WCSession) {
print("session did become inactive")
}

func sessionDidDeactivate(_ session: WCSession) {
print("seesion did deactivate")
}
}

Now we need to add this session method to receive the data sent from the watchOS:

/// Receive messages from watchOS
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String : Any]) -> Void) {
print("Received message: \(message)")
}

As you can see, this method receives the “didReceiveMessage message: [String: Any]” parameter, which gives us the possibility of receiving what we want, so we can create entities to send as messages/data.
All our entities will be created in the Shared/models directory.

Important:

  • Remember to add both targets to every created file inside the Shared folder.
  • The purpose of this post is not about entities/objects, so we assume that all of us understand POO. I defined entities to represent a typical gym workout.

Let's start with our main entity: Workout

struct Workout: Identifiable, Hashable, Codable {
var id: UUID
var name: String
var exercises: [Exercise]

static let emptyUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000000")!

init() {
self.id = Workout.emptyUUID
self.name = ""
self.exercises = []
}

init(id: UUID, name: String, exercises: [Exercise]) {
self.id = id
self.name = name
self.exercises = exercises
}

static func == (lhs: Workout, rhs: Workout) -> Bool {
lhs.id == rhs.id &&
lhs.name == rhs.name &&
lhs.exercises == rhs.exercises
}

func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(name)
hasher.combine(exercises)
}
}

// Decoding
extension Workout {
func toDictionary() -> [String: Any]? {
let encoder = JSONEncoder()
do {
let data = try encoder.encode(self)
if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
return json
}
} catch {
print("Error encoding Workout: \(error)")
}
return nil
}

static func fromDictionary(_ dictionary: [String: Any]) -> Workout? {
let decoder = JSONDecoder()
do {
let data = try JSONSerialization.data(withJSONObject: dictionary, options: [])
let workout = try decoder.decode(Workout.self, from: data)
return workout
} catch {
print("Error decoding Workout: \(error)")
}
return nil
}
}

Let's continue with the rest of the entities:
Exercise:

struct Exercise: Identifiable, Hashable, Codable {
var id: UUID
var name: String
var sets: [ExerciseSet]

init(id: UUID, name: String, sets: [ExerciseSet]) {
self.id = id
self.name = name
self.sets = sets
}

func isComplete() -> Bool {
return sets.allSatisfy { $0.done }
}

func countCompletedExerciseSets() -> Int {
return sets.filter { $0.done }.count
}
}

ExerciseSet:

struct ExerciseSet: Identifiable, Hashable, Codable {
var id: UUID
var reps: Int?
var weight: Double?
var done: Bool

init(
id: UUID,
reps: Int? = nil,
weight: Double? = nil,
done: Bool
) {
self.id = id
self.reps = reps
self.weight = weight
self.done = done
}

init() {
self.id = Workout.emptyUUID
self.reps = 12
self.weight = 10.0
self.done = false
}
}

Now we need to create our WorkoutManagerProtocol and implement it on both managers:

protocol WorkoutManagerProtocol: ObservableObject {
var workout: Workout { get }
var selectedExercise: Exercise? { get set }

func handleWorkoutChange()
}
class IOSManager: NSObject, ObservableObject, WCSessionDelegate, WorkoutManagerProtocol {
var session: WCSession
@Published var workout = Workout()
@Published var selectedExercise: Exercise? { // Every time that selected change it update the workout property
didSet {
DispatchQueue.main.async {
if let updatedExercise = self.selectedExercise {
if let index = self.workout.exercises.firstIndex(where: { $0.id == updatedExercise.id }) {
self.workout.exercises[index] = updatedExercise
print("Workout updated with new selected exercise")
}
}
}
}
}
// Rest of code ...

func handleWorkoutChange() { // Keep the selectedExercise updated every time that Workout change
guard let _ = selectedExercise else {
return
}

if let updatedExercise = workout.exercises.first(where: { $0.id == self.selectedExercise?.id }) {
self.selectedExercise = updatedExercise
}
}
}
class WatchOSManager: NSObject, WCSessionDelegate, ObservableObject, WorkoutManagerProtocol {
var session: WCSession
@Published var workout: Workout = Workout()
@Published var selectedExercise: Exercise? { // Every time that selected change it update the workout property
didSet {
DispatchQueue.main.async {
if let updatedExercise = self.selectedExercise {
if let index = self.workout.exercises.firstIndex(where: { $0.id == updatedExercise.id }) {
self.workout.exercises[index] = updatedExercise
print("Workout updated with new selected exercise")
}
}
}
}
}
// Rest of code ...

func handleWorkoutChange() { // Keep the selectedExercise updated every time that Workout change
guard let _ = selectedExercise else {
return
}

if let updatedExercise = workout.exercises.first(where: { $0.id == self.selectedExercise?.id }) {
self.selectedExercise = updatedExercise
}
}
}

Ok, now we can modify the session method, to decode the Workout entity when we will receive it using our new workout property:

    /// Receive messages from watchOS
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String : Any]) -> Void) {
if let workout = Workout.fromDictionary(message) {
DispatchQueue.main.async {
self.workout = workout
self.handleWorkoutChange()
print("Received Workout from watchOS: \(workout.name)")
}
replyHandler(["response": "Workout received successfully"])
} else {
replyHandler(["error": "Failed to decode Workout"])
}
}

Now we need to create at least one more method to send messages to watchOS:

@MainActor
func sendDailyWorkoutToWatchOS() {
print("Attempting to send a message to watchOS")
if session.activationState == .activated && session.isReachable {
print("Session is reachable")
if let workoutData = workout.toDictionary() {
session.sendMessage(workoutData, replyHandler: { response in
if let reply = response["response"] as? String {
print("Reply from iOS!: \(reply)")
} else if let error = response["error"] as? String {
print("Error from iOS: \(error)")
}
}, errorHandler: { error in
print("Failed to send message: \(error.localizedDescription)")
})
}
} else {
print("Session is not reachable or not activated")
}
}

Notice that we use the “session.sendMessage(…)” to send data to watchOS, and we have the “replyHandler” property to handle the response from watchOS (if exists).

At this point we can send data from iOS to watchOS..but how do we handle data on watchOS from iOS? Well, we need to create the watchOSManager and implement the same functionalities that we already added on iOSManager:

WatchOSManager:

class WatchOSManager: NSObject, WCSessionDelegate, ObservableObject, WorkoutManagerProtocol {
var session: WCSession
@Published var workout: Workout = Workout()
@Published var selectedExercise: Exercise? {
didSet {
DispatchQueue.main.async {
if let updatedExercise = self.selectedExercise {
if let index = self.workout.exercises.firstIndex(where: { $0.id == updatedExercise.id }) {
self.workout.exercises[index] = updatedExercise
print("Workout updated with new selected exercise")
}
}
}
}
}

override init() {
self.session = .default
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
} else {
print("watchOS to iOS connection not supported")
}
}

func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("watchOS: Error on session activation:: \(error.localizedDescription)")
return
}
print("Watch session activation complete: \(activationState.rawValue)")
}

// Receives messages/data from iOS
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String : Any]) -> Void) {
if let workout = Workout.fromDictionary(message) {
DispatchQueue.main.async {
self.workout = workout
self.handleWorkoutChange()
}
replyHandler(["response": "Workout received successfully"])
} else {
replyHandler(["error": "Failed to decode workout"])
}
}

// Send data to iOS
@MainActor
private func sendWorkoutToIOS() {
print("Attempting to send Workout to iOS")
if session.isReachable {
print("Session is reachable")
if let workoutData = workout.toDictionary() {
session.sendMessage(workoutData, replyHandler: { response in
if let reply = response["response"] as? String {
print("Reply from iOS!: \(reply)")
} else if let error = response["error"] as? String {
print("Error from iOS: \(error)")
}
}, errorHandler: { error in
print("Failed to send message: \(error.localizedDescription)")
})
}
} else {
print("Session is not reachable")
}
}

func handleWorkoutChange() {
guard let _ = selectedExercise else {
return
}

if let updatedExercise = workout.exercises.first(where: { $0.id == self.selectedExercise?.id }) {
self.selectedExercise = updatedExercise // Keep the selectedExercise updated every time that Workout change
}
}
}

With both IOSManager and WatchOSManager set up for communication, the next step is to create basic views for displaying data on iOS and watchOS. These views will allow us to visualize the workout and exercise information while testing the interaction between both devices.

Important:
While we could design highly abstract and reusable views for both iOS and watchOS, that’s not the focus of this post. Instead, we’ll create separate views for each target, with some shared components. Additionally, we won’t dive into the detailed design or functionality of each view, as the primary goal here is to demonstrate how the two devices communicate. The same applies to the IOSManager and WatchOSManager, where there’s room for further abstraction.

Shared Views:

ExerciseCardView:

struct ExerciseCardView: View {
var exercise: Exercise

var body: some View {
HStack {
VStack(alignment: .leading) {
Spacer()
Text(exercise.name)
Spacer()
Text("\(exercise.countCompletedExerciseSets())/\(exercise.sets.count) completed")
.foregroundStyle(.secondary)
.font(.footnote)
Spacer()
}
Spacer()
Image(systemName: exercise.isComplete() ? "checkmark.circle.fill" : "clock.fill")
.foregroundStyle(exercise.isComplete() ? .green : .yellow)
}
}
}

WeightPickerView:

struct WeightPickerView: View {
@Binding var weight: Double
let weightOptions: [Double]

var body: some View {
Picker("Weight", selection: $weight) {
ForEach(weightOptions, id: \.self) { weight in
Text("\(weight, specifier: "%.1f") kg").tag(weight)
}
}
.pickerStyle(WheelPickerStyle())
.frame(height: 90)
}
}

RepsPickerView:

struct RepsPickerView: View {
@Binding var reps: Int
let repsOptions: [Int]

var body: some View {
Picker("Reps", selection: $reps) {
ForEach(repsOptions, id: \.self) { reps in
Text("\(reps)").tag(reps)
}
}
.pickerStyle(WheelPickerStyle())
.frame(height: 90)
}
}

iOS Views:

WorkoutListView: main list of Exercise

struct WorkoutListView: View {
@EnvironmentObject var manager: IOSManager

var body: some View {
List(manager.workout.exercises, selection: $manager.selectedExercise) { exercise in
NavigationLink {
ExerciseDetailsView()
.environmentObject(manager)
} label: {
ExerciseCardView(exercise: exercise)
}
.tag(exercise)
}
.navigationTitle(manager.workout.name)
.navigationBarTitleDisplayMode(.large)
}
}

ExerciseDetailsView: details where we navigate to.

struct ExerciseDetailsView: View {
@EnvironmentObject var manager: IOSManager

@State private var weightOptions: [Double] = Array(stride(from: 0.0, to: 200.0, by: 0.5))
@State private var repsOptions: [Int] = Array(1...50)

var body: some View {
if let selectedExercise = manager.selectedExercise {
List(0..<selectedExercise.sets.count, id: \.self) { index in
HStack {
Text("Set \(index + 1)")
WeightPickerView(
weight: Binding(
get: { selectedExercise.sets[index].weight ?? 0.0 },
set: { newWeight in
updateWeight(forSetAt: index, with: newWeight)
}
),
weightOptions: weightOptions
)
.disabled(index != 0 && !selectedExercise.sets[index - 1].done)

RepsPickerView(
reps: Binding(
get: { selectedExercise.sets[index].reps ?? 0 },
set: { newReps in
updateReps(forSetAt: index, with: newReps)
}
),
repsOptions: repsOptions
)
.disabled(index != 0 && !selectedExercise.sets[index - 1].done)

Toggle("", isOn: Binding(
get: { selectedExercise.sets[index].done },
set: { newValue in
updateDoneStatus(forSetAt: index, isDone: newValue)
}
))
.disabled(index != 0 && !selectedExercise.sets[index - 1].done)
}
}
.listStyle(.grouped)
.navigationTitle(selectedExercise.name)
}
}

private func updateWeight(forSetAt index: Int, with newWeight: Double) {
guard var _ = manager.selectedExercise else { return }
manager.selectedExercise?.sets[index].weight = newWeight
}

private func updateReps(forSetAt index: Int, with newReps: Int) {
guard var _ = manager.selectedExercise else { return }
manager.selectedExercise?.sets[index].reps = newReps
}

private func updateDoneStatus(forSetAt index: Int, isDone: Bool) {
guard var _ = manager.selectedExercise else { return }
Task {
manager.selectedExercise?.sets[index].done = isDone
try await Task.sleep(nanoseconds: 250_000_000) // Delay to avoid thread races

if isDone {
manager.sendWorkoutToWatchOS()
}
}
}
}

WatchOS Views:

WorkoutListView: main list of Exercise

struct WorkoutListView: View {
@EnvironmentObject var manager: WatchOSManager

var body: some View {
List(manager.workout.exercises, id: \.id, selection: $manager.selectedExercise) { exercise in
NavigationLink {
ExerciseDetailsView()
.environmentObject(manager)
} label: {
ExerciseCardView(exercise: exercise)
}
.tag(exercise)
}
.navigationTitle(manager.workout.name)
.navigationBarTitleDisplayMode(.large)
}
}

WorkoutDetailsView: details where we navigate to.

struct ExerciseDetailsView: View {
@EnvironmentObject var manager: WatchOSManager

@State private var currentSetIndex = 0
@State private var currentWeight: Double = 0.0
@State private var currentReps: Int = 0
@State private var weightOptions: [Double] = Array(stride(from: 0.0, to: 200.0, by: 0.5))
@State private var repsOptions: [Int] = Array(1...50)

var body: some View {
if let selectedExercise = manager.selectedExercise {
VStack(alignment: .leading) {
Text("Set \(currentSetIndex + 1) of \(selectedExercise.sets.count)")
.font(.headline)
.padding()

// MARK: Pickers
HStack {
WeightPickerView(weight: $currentWeight, weightOptions: weightOptions)
.onChange(of: currentWeight) { _, newValue in
updateWeightForCurrentSet()
}

RepsPickerView(reps: $currentReps, repsOptions: repsOptions)
.onChange(of: currentReps) { _, newValue in
updateRepsForCurrentSet()
}
}
.padding(.bottom, 25)
}
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button(action: previousSet) {
Image(systemName: "chevron.left")
}
.disabled(currentSetIndex == 0)

Button(action: completeCurrentSet) {
Image(systemName: "checkmark")
}
.disabled(selectedExercise.isComplete())
.controlSize(.large)
.background(selectedExercise.sets[currentSetIndex].done ? Color.green : .red, in: Capsule())

Button(action: nextSet) {
Image(systemName: "chevron.right")
}
.disabled(currentSetIndex == selectedExercise.sets.count - 1)
}
}
.onAppear {
currentWeight = selectedExercise.sets[currentSetIndex].weight ?? 0.0
currentReps = selectedExercise.sets[currentSetIndex].reps ?? 0
}
.navigationTitle(selectedExercise.name)
}
}

// MARK: LOG ExerciseSet
private func completeCurrentSet() {
Task {
if var selectedExercise = manager.selectedExercise {
selectedExercise.sets[currentSetIndex].weight = currentWeight
selectedExercise.sets[currentSetIndex].reps = currentReps
selectedExercise.sets[currentSetIndex].done = true

manager.selectedExercise = selectedExercise
try await Task.sleep(nanoseconds: 250_000_000) // Delay to avoid thread races

manager.sendWorkoutToIOS()

withAnimation {
if currentSetIndex < selectedExercise.sets.count - 1 {
currentSetIndex += 1
currentWeight = selectedExercise.sets[currentSetIndex].weight ?? 0.0
currentReps = selectedExercise.sets[currentSetIndex].reps ?? 0
}
}
}
}
}

private func updateWeightForCurrentSet() {
if var selectedExercise = manager.selectedExercise {
manager.selectedExercise?.sets[currentSetIndex].weight = currentWeight
}
}

private func updateRepsForCurrentSet() {
if var selectedExercise = manager.selectedExercise {
manager.selectedExercise?.sets[currentSetIndex].reps = currentReps
}
}

private func nextSet() {
withAnimation {
if currentSetIndex < (manager.selectedExercise?.sets.count ?? 0) - 1 {
currentSetIndex += 1
currentWeight = manager.selectedExercise?.sets[currentSetIndex].weight ?? 0.0
currentReps = manager.selectedExercise?.sets[currentSetIndex].reps ?? 0
}
}
}

private func previousSet() {
withAnimation {
if currentSetIndex > 0 {
currentSetIndex -= 1
currentWeight = manager.selectedExercise?.sets[currentSetIndex].weight ?? 0.0
currentReps = manager.selectedExercise?.sets[currentSetIndex].reps ?? 0
}
}
}
}

At this point, we are ready to execute our app, but wait.. where is the data?!
Ok, let's create a factory with fake data!

WorkoutFactory: We will use this factory to simulate fake API data.

final class WorkoutFactory {
/// Generates a complete fake Workout object
static func createChestAndBicepsWorkout() -> Workout {
// MARK: ExerciseSets
let declinePress = [
ExerciseSet(
id: UUID(),
reps: 12,
weight: 25,
done: false
),
ExerciseSet(
id: UUID(),
reps: 10,
weight: 35,
done: false
),
ExerciseSet(
id: UUID(),
reps: 8,
weight: 45,
done: false
),
ExerciseSet(
id: UUID(),
reps: 6,
weight: 55,
done: false
)
]
let cableCrossover = [
ExerciseSet(
id: UUID(),
reps: 12,
weight: 25,
done: false
),
ExerciseSet(
id: UUID(),
reps: 10,
weight: 35,
done: false
),
ExerciseSet(
id: UUID(),
reps: 8,
weight: 45,
done: false
)
]
let dumbbellCurl = [
ExerciseSet(
id: UUID(),
reps: 12,
weight: 15,
done: false
),
ExerciseSet(
id: UUID(),
reps: 10,
weight: 25,
done: false
),
ExerciseSet(
id: UUID(),
reps: 8,
weight: 30,
done: false
)
]

// MARK: Exercises
let exercises = [
Exercise(
id: UUID(),
name: "Cable Crossover",
sets: cableCrossover
),
Exercise(
id: UUID(),
name: "Decline Press",
sets: declinePress
),
Exercise(
id: UUID(),
name: "Dumbell Curl",
sets: dumbbellCurl
)
]

// MARK: Workout
let workout = Workout(
id: UUID(),
name: "Chest and Biceps",
exercises: exercises
)
return workout
}
}

Now we need to add the factory in the Init() method of our IOSManager, so when the manager is created the factory returns a fake Workout and then sends it to watchOS:

IOSManager:

override init() {
self.session = .default
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
} else {
print("iOS to watchOS connection not supported")
}

self.workout = WorkoutFactory.createChestAndBicepsWorkout() // Fake fetch of Workouts
Task {
await self.sendWorkoutToWatchOS()
}
}

Ok we are ready to compile and run our Workout app:

Check the console logs of the iOS target, and you’ll notice the debug prints where iOS attempts to send the workout data to watchOS. You’ll also see the corresponding response from the watchOS device, confirming the successful transmission.

List views:

Detail views:

Ok, the views look good, let's do some consistency tests between both apps:

Editing on WatchOS:

Editing on iOS:

It works! We’re using both devices simultaneously, with WCSession handling communication whenever an exercise is marked as done.

Here’s the basic flow:

1. Tweek the Pickers (weight/reps), which update the “selectedExercise” property in the managers and keep the workout property updated too.

2. When an ExerciseSet is marked as done, the updated workout is sent to the iOS/watchOS counterpart.

3. The other device receives the workout, checks if its “selectedExercise” needs updating, and synchronizes the data.

Summary

That’s a wrap! We’ve successfully built a minimal workout app for both iOS and watchOS, leveraging WCSession and WatchConnectivity to keep the two devices in sync. Throughout the process, we explored setting up communication channels, sending and receiving messages, and synchronizing workout data between devices. While we’ve covered the basics, there’s a lot more room for improvement, such as abstracting shared code, improving error handling, and integrating features like HealthKit or more sophisticated UI elements. These enhancements can elevate the app experience, but we’ll save that for part 2…

Would you like to know more? Do you need our help? Contact Us!
www.quadiontech.com

No responses yet

Write a response