KMPizza
Kotlin Multiplatform + Pizza = ❤️

Step 23: Create editable Recipe Details View in Swift UI and bind to the shared View Model

For a Floating Action Button add the following struct in RecipesView:

 
struct FloatingActionButton: View {
    var body: some View {
        Button(action: {
            print("Add new recipe") [1]
        }) {
            Image(systemName: "plus.circle.fill") [2]
                .resizable()
                .frame(width: 56, height: 56)
                .foregroundColor(Color.accentColor)
                .shadow(color: .gray, radius: 0.2, x: 1, y: 1)
        }
    } 
}

[1] This closure defines an action that gets executed when the user clicks the button, for now it’s just printing in log output
[2] Here we describe how the button looks

Then use it in RecipesView like this:

 
var body: some View {
        ZStack(alignment: .bottomTrailing) { [1]
            List(state.recipes, id: \.id) { recipe in
                NavigationLink(destination: RecipeDetailView (id: recipe.id.toKotlinLong())) {
                    RecipeView(item: recipe)
                    
                }
            }
            .listStyle(PlainListStyle())
            FloatingActionButton() [2]
                .padding()
                .frame(alignment: .bottomTrailing)
        }
    }

[1] We’re using ZStack to place the FloatingActionButton over the Recipe List
[2] Use the FloatingActionButton struct we created before

Build and run the app.

Big blue add recipe button

Click on the button and you’ll see Add new recipe printed in the log.
We need to change this to actually be able to add a recipe.
To do so, replace the Button with NavigationLink

 
struct FloatingActionButton: View {
    var body: some View {
        NavigationLink(destination: RecipeDetailView (id: nil)) {
            Image(systemName: "plus.circle.fill")
                .resizable()
                .frame(width: 70, height: 70)
                .foregroundColor(Color.accentColor)
                .shadow(color: .gray, radius: 0.2, x: 1, y: 1)
        }
    }
}

[1] Instead of using a Button that prints a log we’ll navigate to RecipeDetailView

In the RecipeDetailView change the title item so that if we already have a recipe (with recipeId), it displays a title, otherwise it displays a text field:

 
ScrollView {
            KFImage(URL(string: "https://m.media-amazon.com/images/I/413qxEF0QPL._AC_.jpg"))
                .resizable()
                .frame(width: 200, height: 150)
            
            if (recipeId != nil) {
                Text(state.recipe?.title ?? "")
                    .font(.headline)
                    .padding()
            } else {
                TextField("What is the name of this dish?", text: Binding(
                    get: { state.recipe?.title ?? "" },
                    set: { state.viewModel.onTitleChanged(title: $0) }
                ))
                .multilineTextAlignment(.center)
                .padding()
            }
. . .
}

[1] If it’s an old recipe with recipeId, display the title
[2] Otherwise create an input field
[3] The title gets updated according to the recipe title from the shared RecipeDetailsViewModel
[4] when the user enters a new title the value in the shared model is updated via onTitleChanged()

You’ll also need a plus button to add more ingredients or instructions like the one we had in Android.
Add this struct below the RecipeDetailView struct:

 
struct AddButton: View {
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            Image(systemName: "plus.circle.fill")
                .resizable()
                .frame(width: 30, height: 30)
                .foregroundColor(Color.accentColor)
                .shadow(color: .gray, radius: 0.2, x: 1, y: 1)
        }.padding()
    }  
}

Add other structs for the UI, starting with the Ingredients section.
This View shows a simple list of ingredients with respective names, amounts and metric and arranges them in a similar way like in Android:

 
struct Ingredients: View {
    
    var ingredients: [Ingredient]?
    
    var body: some View {
        LazyVStack {
            ForEach(Array(ingredients?.enumerated() ?? [].enumerated()), id: \.offset) { index, ingredient in
                HStack {
                    Text(ingredient.name)
                        .font(.body)
                        .frame(maxWidth: .infinity, alignment: .leading)
                    
                    HStack {
                        Text("\(ingredient.amount, specifier: "%.1f")")
                        Text(ingredient.metric)
                            .font(.body)
                    }
                }
                
            })
        }
        .padding()
    }
}

[1] Here we can’t use id to build a LazyVStack, because all of them has the same id = nil, that’s why we are using enumerated() as workaround to display the ingredients. Otherwise you’ll get an error: LazyVStackLayout: the ID nil is used by multiple child views, this will give undefined results!

The Instructions section is similar to the Ingredients section:

 
struct Instructions: View {
    
    var instructions: [Instruction]? [1]
    
    var body: some View {
        LazyVStack {
            ForEach(instructions ?? [], id: \.self, content: { instruction in [1]
                HStack {
                    Text("\(instruction.order). ")
                        .font(.body)
                    Text(instruction.description_)
                        .font(.body)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
                .padding()
                
            })
        }
    }   
}

[1] Using \.self is another way to avoid the LazyVStackLayout error, but for instructions we could also use \.order, because every order is unique

Previous structs were not editable, but we also want the user to be able to add or edit recipes in our RecipeDetailsView:

 
struct EditIngredients: View {
    
    var ingredients: [Ingredient]?
    var viewModel: RecipeDetailsViewModel [1]
    
    @State private var name: String = "" [2]
    @State private var amountString: String = ""
    @State private var metric: String = ""
    
    private let formatter = NumberFormatter()
    
    private var amount: Double { [3]
        return formatter.number(from: amountString)?.doubleValue ?? 0
    }
    
    private var isValid: Bool {
        return name != "" && amount > 0 && metric != ""
    }
      
    var body: some View {
        Ingredients(ingredients: ingredients)
        
        HStack {
            TextField("Ingredient", text: $name)
                .frame(maxWidth: .infinity, alignment: .leading)
            
            HStack {
                TextField("Amount", text: $amountString)
                TextField("Metric", text: $metric)
            }
        }
        .font(.body)
        
        AddButton(action: {
            viewModel.onIngredientsChanged(ingredient: Ingredient(id: nil, name: name, amount: amount, metric: metric)) [4]
            name = ""
            amountString = ""
            metric = ""
        })
        .padding()
    }
}

[1] This creates an instance of the shared RecipeDetailsViewModel, so that we can use onIngredietnsChanged() function from it
[2] Name, amountString and metric are editable states
[3] In Swift Optional and NumberFormatter() don’t get along well, therefore we need cusotm logic to bind values to TextField, because we don’t want to see anything but a hint in the Amount TextField if amount==nil
[4] Here we only care about updating the RecipeDetailsViewModel after the user clicks the Add (Ingredient) Button again: then the current state of the previous ingredient is refreshed in the RecipeDetailsViewModel

We use the same principle for EditInstructions:

struct EditInstructions: View {
    
    var instructions: [Instruction]?
    var viewModel: RecipeDetailsViewModel
    
    @State private var description: String = ""
    
    
    var body: some View {
        
        Instructions(instructions: instructions)
        
        HStack {
            Text ("\((instructions?.count ?? 0) + 1). ")
                .font(.body)
            TextField("Description", text: $description)
                .font(.body)
                .frame(maxWidth: .infinity, alignment: .leading)
    
        }
        
        
        AddButton(action: {
            viewModel.onInstructionsChanged(instruction: Instruction(id: nil, order: Int32((instructions?.count ?? 0) + 1), description: description))
            description = ""
        })
        
            .padding() 
    } 
}

Then simply add all these components to the RecipeDetailView body:

 
 var body: some View {
        ScrollView {
            KFImage(URL(string: "https://m.media-amazon.com/images/I/413qxEF0QPL._AC_.jpg"))
                .resizable()
                .frame(width: 200, height: 150)

            if (recipeId != nil) {
                Text(state.recipe?.title ?? "")
                    .font(.headline)
            } else {
                TextField("What is the name of this dish?", text: Binding(
                    get: { state.recipe?.title ?? "" },
                    set: { state.viewModel.onTitleChanged(title: $0) }
                )).multilineTextAlignment(.center)
            }
            
            Text("Ingredients") [1]
                .font(.subheadline)
            if (recipeId != nil) {
                Ingredients(ingredients: state.recipe?.ingredients)
            } else {
                EditIngredients(ingredients: state.recipe?.ingredients, viewModel: state.viewModel )
            }
            
            Text("Instructions") [2]
                .font(.subheadline)
            if (recipeId != nil) {
                Instructions(instructions: state.recipe?.instructions)
            } else {
                EditInstructions(instructions: state.recipe?.instructions, viewModel: state.viewModel )
            }
        }
    }

[1] If there’s already a recipeId, show the recipe details, otherwise use the editable struct
[2] Same for the instructions section

Build and run the app. If you choose a recipe from the list you’ll see the details view as before:

Old recipe details screen

If you click on the big blue add recipe button you’ll see the empty add recipe screen:

Add new recipe details screen

With this setup we can either view the recipe details OR add a new one, meaning we can’t edit an existing recipe. But that’s enough for now, we can adjust this behaviour later.

Last but not least, we need a button to save the new recipe.
Add saveRecipe() function to RecipeDetailState to leverage the method from the shared RecipeDetailsViewModel:

 
func saveRecipe(){
        viewModel.saveRecipe()
    }

Create a style for the submit button:

 
struct FilledButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration
            .label
            .font(.title3)
            .foregroundColor(.white)
            .padding(.horizontal)
            .padding(.vertical, 12)
            .frame(maxWidth: .infinity)
            .background(configuration.isPressed ? Color.blue.opacity(0.3) : Color.blue)
            .cornerRadius(25)
    }
}

Then place it at the end of the body inside RecipeDetailView:

 
if (recipeId == nil) {
                Button("Save recipe") {
                    state.saveRecipe()
                }
                .buttonStyle(FilledButtonStyle())
                .padding()
            }

Build and run the app. Here it is, our saveRecipe() button:

Add new recipe details screen

We want the RecipeDetailsView to close automatically once the user saves the recipe. Add to the top of RecipeDetailView struct:

 
@Environment(\.presentationMode) var presentationMode

Use it to close the view when clicking on save Button (this will later be enhanced with upload flow):

 
        Button("Save recipe") {
            state.saveRecipe()
            presentationMode.wrappedValue.dismiss()
        }

Build and run the iOS app.
Now we can fill out the form and press the save button, but there’s no new recipe in the list…Why?
We’ll investigate it next time.