# Motion patterns

Companion material for the core-motion skill. Each section is a self-contained
pattern that goes beyond the basics in [../SKILL.md](../SKILL.md).

## Contents

- [A shared @Observable motion service for SwiftUI](#a-shared-observable-motion-service-for-swiftui)
- [CMBatchedSensorManager for high-rate capture](#cmbatchedsensormanager-for-high-rate-capture)
- [Head tracking with CMHeadphoneMotionManager](#head-tracking-with-cmheadphonemotionmanager)
- [A step dashboard](#a-step-dashboard)
- [Switching app modes from activity](#switching-app-modes-from-activity)
- [Water submersion on watchOS](#water-submersion-on-watchos)

## A shared @Observable motion service for SwiftUI

Keep the single `CMMotionManager` inside one main-actor service. Views observe
its published angles and never touch the manager directly.

```swift
import CoreMotion
import Observation

@Observable @MainActor
final class TiltService {
    static let shared = TiltService()

    private let hardware = CMMotionManager()
    private(set) var pitch = 0.0
    private(set) var roll = 0.0
    private(set) var yaw = 0.0
    private(set) var push = CMAcceleration()
    private(set) var isRunning = false

    private init() {}

    func begin(every interval: TimeInterval = 1.0 / 30.0) {
        guard hardware.isDeviceMotionAvailable, !isRunning else { return }
        hardware.deviceMotionUpdateInterval = interval
        hardware.startDeviceMotionUpdates(using: .xArbitraryZVertical, to: .main) { [weak self] sample, _ in
            guard let sample else { return }
            MainActor.assumeIsolated {   // the handler runs on OperationQueue.main
                guard let self else { return }
                self.pitch = sample.attitude.pitch
                self.roll = sample.attitude.roll
                self.yaw = sample.attitude.yaw
                self.push = sample.userAcceleration
            }
        }
        isRunning = true
    }

    func end() {
        hardware.stopDeviceMotionUpdates()
        isRunning = false
    }
}
```

A bubble level built on it. The surface counts as level while both pitch and
roll stay under 0.05 radians; 30 Hz is enough for smooth movement.

```swift
import SwiftUI

struct BubbleLevel: View {
    private let tilt = TiltService.shared
    private let tolerance = 0.05
    private static let angle = FloatingPointFormatStyle<Double>.number.precision(.fractionLength(2))

    private var isFlat: Bool {
        abs(tilt.pitch) < tolerance && abs(tilt.roll) < tolerance
    }

    var body: some View {
        VStack(spacing: 24) {
            Circle()
                .fill(isFlat ? .green : .orange)
                .frame(width: 44, height: 44)
                .offset(x: tilt.roll * 120, y: tilt.pitch * 120)
            Text("Pitch \(tilt.pitch.formatted(Self.angle))")
            Text("Roll \(tilt.roll.formatted(Self.angle))")
        }
        .onAppear { tilt.begin(every: 1.0 / 30.0) }
        .onDisappear { tilt.end() }
    }
}
```

## CMBatchedSensorManager for high-rate capture

`CMBatchedSensorManager` hands over accelerometer and device-motion samples in
batches at a high rate, which suits swing and stroke analysis. The async
update sequences need watchOS 10 or later. Before starting, confirm
`CMBatchedSensorManager.isAccelerometerSupported` and that
`authorizationStatus` is neither `.denied` nor `.restricted`.

```swift
import CoreMotion

#if os(watchOS)
@available(watchOS 10.0, *)
@MainActor
final class SwingRecorder {
    private let batcher = CMBatchedSensorManager()
    private var reader: Task<Void, Never>?
    private(set) var lastSample: CMAccelerometerData?

    func record() {
        guard CMBatchedSensorManager.isAccelerometerSupported else { return }
        switch CMBatchedSensorManager.authorizationStatus {
        case .denied, .restricted: return
        default: break
        }
        reader = Task { [weak self, batcher] in
            do {
                for try await chunk in batcher.accelerometerUpdates() {
                    if Task.isCancelled { break }
                    for sample in chunk {
                        _ = (sample.acceleration, sample.timestamp)  // feed the analyzer
                    }
                    self?.lastSample = chunk.last
                }
            } catch {
                // surface the stream failure to the UI
            }
        }
    }

    func finish() {
        reader?.cancel()
        reader = nil
        batcher.stopAccelerometerUpdates()
    }
}
#endif
```

To learn the delivered rate, start the stream (for example with
`startAccelerometerUpdates()`) and then read `accelerometerDataFrequency`;
`deviceMotionDataFrequency` is the device-motion counterpart. Both are
read-only. Use the numbers to size ring buffers, throttle what the UI redraws,
or pick a downsampling factor.

## Head tracking with CMHeadphoneMotionManager

`CMHeadphoneMotionManager` reports head orientation from supported AirPods,
such as AirPods Pro and AirPods Max. On iOS and macOS the app needs
`NSMotionUsageDescription`.

```swift
import CoreMotion

@MainActor
final class HeadTracker: NSObject, CMHeadphoneMotionManagerDelegate {
    private let earbuds = CMHeadphoneMotionManager()
    private(set) var isConnected = false

    func begin() {
        guard earbuds.isDeviceMotionAvailable else { return }
        earbuds.delegate = self
        earbuds.startConnectionStatusUpdates()
        earbuds.startDeviceMotionUpdates(to: .main) { motion, error in
            guard let pose = motion?.attitude, error == nil else { return }
            _ = (pose.pitch, pose.yaw)   // nod and turn
        }
    }

    func end() {
        earbuds.stopDeviceMotionUpdates()
        earbuds.stopConnectionStatusUpdates()
    }

    nonisolated func headphoneMotionManagerDidConnect(_ source: CMHeadphoneMotionManager) {
        Task { @MainActor in self.isConnected = true }
    }

    nonisolated func headphoneMotionManagerDidDisconnect(_ source: CMHeadphoneMotionManager) {
        Task { @MainActor in self.isConnected = false }
    }
}
```

`startConnectionStatusUpdates()` delivers connect and disconnect callbacks even
while no motion session is running. Stop both the motion stream and the
connection updates when finished.

## A step dashboard

```swift
import CoreMotion
import Observation
import SwiftUI

@Observable @MainActor
final class StepTally {
    private let counter = CMPedometer()
    private(set) var steps = 0
    private(set) var meters = 0.0
    private(set) var floorsUp = 0

    func begin() {
        guard CMPedometer.isStepCountingAvailable() else { return }
        let midnight = Calendar.current.startOfDay(for: .now)

        counter.queryPedometerData(from: midnight, to: .now) { data, _ in
            guard let data else { return }
            Task { @MainActor in self.apply(data) }
        }
        counter.startUpdates(from: midnight) { data, _ in
            guard let data else { return }
            Task { @MainActor in self.apply(data) }
        }
    }

    func end() { counter.stopUpdates() }

    private func apply(_ data: CMPedometerData) {
        steps = data.numberOfSteps.intValue
        meters = data.distance?.doubleValue ?? meters
        floorsUp = data.floorsAscended?.intValue ?? floorsUp
    }
}

struct DailyStepsView: View {
    @State private var tally = StepTally()

    var body: some View {
        List {
            LabeledContent("Steps", value: tally.steps, format: .number)
            LabeledContent("Distance") {
                Text(Measurement(value: tally.meters, unit: UnitLength.meters),
                     format: .measurement(width: .abbreviated))
            }
            if CMPedometer.isFloorCountingAvailable() {
                LabeledContent("Floors climbed", value: tally.floorsUp, format: .number)
            }
        }
        .onAppear { tally.begin() }
        .onDisappear { tally.end() }
    }
}
```

## Switching app modes from activity

A navigation-style app can change its presentation when the user starts
driving or cycling. Ignore low-confidence readings, check the flags in
priority order, and keep the current mode while stationary so a red light
does not flip a driver back to walking.

```swift
import CoreMotion

enum TravelMode { case walking, cycling, driving }

@MainActor
final class TravelModeDetector {
    private let classifier = CMMotionActivityManager()
    private(set) var mode: TravelMode = .walking

    func begin() {
        guard CMMotionActivityManager.isActivityAvailable() else { return }
        classifier.startActivityUpdates(to: .main) { [weak self] update in
            guard let update, update.confidence != .low else { return }
            MainActor.assumeIsolated {
                guard let self else { return }
                if update.automotive {
                    self.mode = .driving
                } else if update.cycling {
                    self.mode = .cycling
                } else if update.running || update.walking {
                    self.mode = .walking
                }
                // stationary: leave mode unchanged
            }
        }
    }

    func end() { classifier.stopActivityUpdates() }
}
```

## Water submersion on watchOS

Decide support with `CMWaterSubmersionManager.waterSubmersionAvailable`, never
with a model list. Apple Watch Ultra supports submersion, and Apple Watch
Series 10 supports Shallow Depth and Pressure, so do not describe the feature
as Ultra-only.

Setup:

1. Add `NSMotionUsageDescription`.
2. Choose the capability. Dives no deeper than 6 m are covered by the
   capability named Shallow Depth and Pressure. Deeper dives, to 40 m, need the Submerged Depth and Pressure
   entitlement, which requires an application to Apple.
3. Add `underwater-depth` to `WKBackgroundModes`. That keeps the app frontmost
   during a dive and makes it eligible to launch automatically on submersion.

Check availability before creating the manager, keep a strong reference to
it, and assign its `delegate`.

```swift
import CoreMotion

final class DiveMonitor: NSObject, CMWaterSubmersionManagerDelegate {
    private var sensor: CMWaterSubmersionManager?

    func begin() {
        guard CMWaterSubmersionManager.waterSubmersionAvailable else { return }
        let manager = CMWaterSubmersionManager()
        manager.delegate = self
        sensor = manager
    }

    func manager(_ source: CMWaterSubmersionManager, didUpdate event: CMWaterSubmersionEvent) {
        let underwater = event.state == .submerged
        _ = underwater
    }

    func manager(_ source: CMWaterSubmersionManager, didUpdate measurement: CMWaterSubmersionMeasurement) {
        if let depth = measurement.depth { _ = depth.value }   // meters
    }

    func manager(_ source: CMWaterSubmersionManager, didUpdate measurement: CMWaterTemperature) {
        _ = measurement.temperature.value
    }

    func manager(_ source: CMWaterSubmersionManager, errorOccurred error: any Error) {
        // CMError.notEntitled arrives here when the capability is missing
    }
}
```

An app that lacks the capability or entitlement gets `CMError.notEntitled` in
`manager(_:errorOccurred:)` and no depth data.
