I am using The Composable Architecture for a SwiftUI app, and I am facing issues with the view not updating when my store's state changes.
My screen flow is as follows:
RootView -> RecipeListView -> RecipeDetailView --> RecipeFormView (presented as a sheet).
The RecipeListView
has a list of recipes. Tapping on the list item shows the RecipeDetailView
.
This detail view can present another view (as a sheet), which allows you to edit the recipe.
The issue is that after editing the recipe, the view does not update when I dismiss the sheet and go back. None of the views in the hierarchy update show the updated recipe item.
I can confirm that the state of the store has changed. This is the feature code:
import Combine
import ComposableArchitecture
struct RecipeFeature: ReducerProtocol {
let env: AppEnvironment
struct State: Equatable {
var recipes: [Recipe] = []
}
enum Action: Equatable {
case onAppear
case recipesLoaded([Recipe])
case createOrUpdateRecipe(Recipe)
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case.onAppear:
let recipes = env.recipeDB.fetchRecipes()
return .init(value: .recipesLoaded(recipes))
case .recipesLoaded(let recipes):
state.recipes = recipes
return .none
case .createOrUpdateRecipe(let recipe):
if let index = state.recipes.firstIndex(of: recipe) {
state.recipes[index].title = recipe.title
state.recipes[index].caption = recipe.caption
}
else {
state.recipes.append(recipe)
}
return .none
}
}
}
typealias RecipeStore = Store<RecipeFeature.State, RecipeFeature.Action>
The createOrUpdateRecipe
action does not work in the case of editing a recipe. When adding a new recipe, the views update successfully.
If I assign a new ID as follows:
if let index = state.recipes.firstIndex(of: recipe) {
state.recipes[index].id = UUID() // <-- this will trigger a view update
state.recipes[index].title = recipe.title
state.recipes[index].caption = recipe.caption
}
The RecipeListView
will update, but the RecipeDetailView` still does not update. Also, I don't want to assign a new ID just to trigger an update, since this action is simply updating the recipe info for an existing recipe object.
Edit:
This is the view place where the action is invoked in RecipeFormView
(lots of UI not shown to save space):
struct RecipeFormView: View {
let store: RecipeStore
// Set to nil if creating a new recipe
let recipe: Recipe?
@StateObject var viewModel: RecipeFormViewModel = .init()
@EnvironmentObject var navigationVM: NavigationVM
var body: some View {
WithViewStore(store) { viewStore in
VStack {
titleContent
.font(.title)
ScrollView {
VStack(alignment: .leading, spacing: SECTION_SPACING) {
// LOTS OF OTHER UI.....
// Done button
ZStack(alignment: .center) {
Color.cyan
VStack {
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 25, height: 25)
.foregroundColor(.white)
}
.padding(10)
}
.onTapGesture {
let recipe = viewModel.createUpdatedRecipe()
viewStore.send(RecipeFeature.Action.createOrUpdateRecipe(recipe))
navigationVM.dismissRecipeForm()
}
}
.padding(16)
}
.textFieldStyle(.roundedBorder)
}
}
.onAppear() {
self.viewModel.setRecipe(recipe: self.recipe)
}
}
}
The view model just does some validation and creates a new Recipe
object with the updated properties.
This is the Recipe
model:
struct Recipe: Identifiable, Equatable, Hashable
{
var id: UUID = UUID()
var title: String
var caption: String
var ingredients: [Ingredient]
var directions: [String]
static func == (lhs: Recipe, rhs: Recipe) -> Bool {
return lhs.id == rhs.id
}
}
The view doesn't update because your Recipe
is (as of per your definition) still equal to the old one. You're just comparing the id.
Change the Recipe struct like this:
struct Recipe: Identifiable, Equatable, Hashable {
var id: UUID = UUID()
var title: String
var caption: String
var ingredients: [Ingredient]
var directions: [String]
static func == (lhs: Recipe, rhs: Recipe) -> Bool {
return lhs.id == rhs.id
&& lhs.title == rhs.title
&& lhs.caption == rhs.caption
}
}