Replies: 2 comments
-
Hi @SebaKrk, if you can share more code it would make it easier for me to give concrete advice. In particular, I am interested in what But, without that information I can give you some general advice. There are a few approaches to this depending on how much control and testability you want out of this dependency. As a very basic first step, you could just not change anything at all. There technically is nothing wrong with you keeping your That will get you up and moving quickly, but there are a few downsides. If the data output by the manager needs to be deeply integrated into the core feature's logic, then you need to be able to access it from within the reducer. So, as a second step, you could regiser your extension WorkoutManager: DependencyKey {
static let liveValue = WorkoutManager()
} And now you get immediate access to it in reducers: @Reducer
struct Feature {
@Dependency(WorkoutManager.self) var workoutManager
} And you can even make it so that both the @main stuct EntryPoint: App {
let workoutManager: WorkoutManager
init() {
workoutManager = WorkoutManager()
prepareDependencies {
$0[WorkoutManager.self] = workoutManager
}
}
var body: some Scene {
WindowGroup {
RootView()
.environment(workoutManager)
}
}
} Now that you can access case .startWorkoutButtonTapped:
// Update any state you want
return .run { send in
workoutManager.startWorkout(workoutType: …)
} And then if you want to observe things happening in the manager, you would expose publishers/async sequences that give access to streams of data, and you would subscribe to those streams when your feature appears: return .onAppear:
return .publisher { send in
workoutManager.$statistics.map(Action.receivedStatistics)
}
return .receivedStatistics(HKStatistics?):
// Compute things with 'HKStatistics' and update state Now you are able to react to things happening in the manager in your reducer, where the rest of the logic of your feature lives. But, there are still some downsides to this. We have moved a bunch of code around, but the dependency is not controllable, which means it will not play nicely with Xcode previews or tests. For example, what if in your preview you wanted to emulate what happens with a particular sequence of stats streaming into your feature? That is impossible because the So, that's why to get the most of out TCA and its ecosystem of tools, we recommend performing a 3rd step of properly controlling this dependency. It takes more work, but it unlocks a whole new world of possibilities. Now, I'm not super familiar with HealthKit, so what I'm about to say won't be 100% correct, but it should give you the gist. You need to design an interface to abstract over how you interact with struct WorkoutClient {
var startWorkout: @Sendable (HKWorkoutConfiguration) async -> Void
// or any AsyncSequence
var statistics: @Sendable () async -> AnyPublisher<HKStatistics?, Never>
} This would replace your extension WorkoutClient {
static let previewValue = Self(
startWorkout: { _ in },
statistics: {
// Simulate some stats
[
…
…
…
]
.publisher
}
)
} Now one wrinkle here is that So, if you are willing to go all the way with this dependency there is a lot of power to be had. You will have the ability to truly explore ever facet of your app from the perspective of Xcode previews and tests, all without ever running on a device. But, it does take work to do. That is the very rough idea of how this all goes down with TCA. You can for the most part just use your manager exactly as you are today, but with a bit more work things can get really nice and dealing with such a large, complex dependency doesn't have to be a pain. |
Beta Was this translation helpful? Give feedback.
-
Hey, yesterday I managed to put together the following (before your input): WorkoutManager along with its protocol and required delegates. import HealthKit
protocol WorkoutManagerTestProtocol {
var heartRateStream: AsyncStream<Double> { get }
func startWorkout()
}
final class WorkoutManagerTest: NSObject, ObservableObject, WorkoutManagerTestProtocol {
private let healthStore = HKHealthStore()
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
var heartRateContinuation: AsyncStream<Double>.Continuation?
var heartRateStream: AsyncStream<Double> {
AsyncStream { continuation in
self.heartRateContinuation = continuation
}
}
var heartRate: Double = 0
func updateForStatistics(_ statistics: HKStatistics?) {
guard let statistics = statistics else { return }
DispatchQueue.main.async {
switch statistics.quantityType {
case HKQuantityType.quantityType(forIdentifier: .heartRate):
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
if let newHeartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) {
print("❤️ Updated heart rate: \(newHeartRate)")
self.heartRate = newHeartRate
self.heartRateContinuation?.yield(newHeartRate)
}
default:
break
}
}
}
func startWorkout() {
let config = HKWorkoutConfiguration()
config.activityType = .walking
config.locationType = .outdoor
do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
builder = session?.associatedWorkoutBuilder()
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: config)
builder?.delegate = self
session?.delegate = self
session?.startActivity(with: .now)
builder?.beginCollection(withStart: .now) { _, _ in }
} catch {
print("Failed to start workout: \(error)")
}
}
}
extension WorkoutManagerTest: HKLiveWorkoutBuilderDelegate {
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
print("🏃♂️ didCollectDataOf: \(collectedTypes)")
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { continue }
let statistics = workoutBuilder.statistics(for: quantityType)
updateForStatistics(statistics)
}
}
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
}
extension WorkoutManagerTest: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {}
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
print("🔄 Workout session changed from \(fromState.rawValue) to \(toState.rawValue)")
}
}
Dependency injection setup for the WorkoutManager: import ComposableArchitecture
private enum WorkoutManagerTestKey: DependencyKey {
static let liveValue: WorkoutManagerTestProtocol = WorkoutManagerTest()
}
extension DependencyValues {
var workoutManagerTest: WorkoutManagerTestProtocol {
get { self[WorkoutManagerTestKey.self] }
set { self[WorkoutManagerTestKey.self] = newValue }
}
}
The client struct acting as an interface to the heart rate data and workout control: import ComposableArchitecture
struct HeartRateClient {
var heartRateStream: AsyncStream<Double>
var start: () -> Void
}
extension DependencyValues {
var heartRateClient: HeartRateClient {
get { self[HeartRateClientKey.self] }
set { self[HeartRateClientKey.self] = newValue }
}
}
private enum HeartRateClientKey: DependencyKey {
static let liveValue: HeartRateClient = {
@Dependency(\.workoutManagerTest) var manager
return HeartRateClient(
heartRateStream: manager.heartRateStream,
start: manager.startWorkout
)
}()
}
The feature reducer handling the heart rate state and actions: import ComposableArchitecture
import Foundation
@Reducer
struct HeartRateFeature {
@Dependency(\.heartRateClient) var heartRateClient
// MARK: - Reducer
var body: some Reducer<State, Action> {
CombineReducers {
BindingReducer()
Reduce { state, action in
switch action {
// MARK: - Binding
case .binding(_):
return .none
// MARK: - Actions
case let .heartRateUpdated(bpm):
print("🧠 TCA Reducer received heartRateUpdated: \(bpm)")
state.heartRate = bpm
return .none
// MARK: - View Actions
case .view(.startHeartAnimation):
state.animateHeart = true
return .none
case .view(.startWorkout):
heartRateClient.start()
return .run { send in
for await bpm in heartRateClient.heartRateStream {
print("🚀 Inside .run got bpm: \(bpm)")
await send(.heartRateUpdated(bpm))
}
}
}
}
}
}
}
import ComposableArchitecture
import Foundation
/// Implementation of `HeartRateFeature` state
extension HeartRateFeature {
@CasePathable
enum Action: ViewAction, BindableAction {
// MARK: - Binding Action
/// Handles changes in bindings for the state.
case binding(BindingAction<State>)
// MARK: - Actions
case heartRateUpdated(Double)
case view(View)
/// Sub-actions for view-related events.
enum View {
case startWorkout
case startHeartAnimation
}
}
}
import ComposableArchitecture
import Foundation
/// Implementation of `HeartRateFeature` state
extension HeartRateFeature {
@ObservableState
struct State: Equatable {
var animateHeart: Bool = false
var heartRate: Double = 0
}
}
SwiftUI view displaying the heart rate and controlling workout start: import SwiftUI
import ComposableArchitecture
@ViewAction(for: HeartRateFeature.self)
struct HeartRateView: View {
@Bindable var store: StoreOf<HeartRateFeature>
var body: some View {
ScrollView {
VStack {
Spacer()
heartRate
Spacer()
Button("Start Workout") {
send(.startWorkout)
}
.buttonStyle(.borderedProminent)
.buttonStyle(.borderedProminent)
}
}
}
private var heartRate: some View {
HStack {
heartImage
Text(store.heartRate.formatted(MetricFormatter.heartRate))
.font(.system(.title, design: .rounded).monospacedDigit().lowercaseSmallCaps())
VStack {
Spacer().frame(height: 10)
Text("BMP")
.font(.footnote)
.baselineOffset(-2)
}
}
}
private var heartImage: some View {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.scaleEffect(store.animateHeart ? 1.4 : 1.0)
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: store.animateHeart)
.onAppear {
send(.startHeartAnimation)
}
}
}
var heartRateStream: AsyncStream<Double> {
AsyncStream { continuation in
self.heartRateContinuation = continuation
}
}
Also, it might be worth splitting this action into two separate actions: case .view(.startWorkout):
heartRateClient.start()
return .run { send in
for await bpm in heartRateClient.heartRateStream {
print("🚀 Inside .run got bpm: \(bpm)")
await send(.heartRateUpdated(bpm))
}
} It works, but is this approach recommended in TCA? |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Hello,
I’m working on a fitness app that utilizes HealthKit to monitor workout sessions. I have a WorkoutManager class that manages the workout session and collects data such as heart rate, energy burned, and distance. In a traditional SwiftUI approach, I used @ObservedObject to observe changes in WorkoutManager and update the view accordingly.
When a workout session starts, I configure an HKWorkoutSession with the desired activity type and location. I then create an HKLiveWorkoutBuilder associated with this session. The builder’s delegate method workoutBuilder(_:didCollectDataOf:) is called whenever new data is collected. In this method, I extract the most recent heart rate value and update the corresponding property in WorkoutManager.
sample code SwiftUI
In SwiftUI, this setup allows the view to automatically update whenever the heart rate changes, thanks to the @published property wrapper.
Now, I’m transitioning to using The Composable Architecture (TCA).
My question is: How should I adapt this pattern to TCA? Specifically, how can I integrate WorkoutManager into the TCA architecture so that heart rate updates are properly observed and the view updates accordingly?
Any guidance or examples on how to approach this in TCA would be greatly appreciated. Thank you!
Beta Was this translation helpful? Give feedback.
All reactions