KMPizza
Kotlin Multiplatform + Pizza = ❤️

Step 28: Refactoring the iOS KMM App: NavigationLink and Binding

There’s always room for perfection, so let’s brush up our iOS app a little bit.
Go to RecipesState and add

 
func getRecipes() {
    viewModel.getRecipes()
}

Then in RecipesView make some small adjustments.
Change

 
    NavigationLink(destination: RecipeDetailView (
        id: recipe.id.toKotlinLong(), 
        isPresented: self.$isRecipeDetailsShown, 
        uploadSuccess: self.$uploadSuccess)
        ) 

to

 
    NavigationLink(destination: RecipeDetailView (
        id: recipe.id [1], 
        isPresented: self.$isRecipeDetailsShown, 
        uploadSuccess: self.$uploadSuccess)
        ) 

[1] We’ll pass to RecipeDetailView a recipeId as Int64? and convert to KotlinLong? later.

Also in onAppear() change

 
if uploadSuccess {
    state.viewModel.getRecipes()
}

to

 
if uploadSuccess {
    state.getRecipes()
}

Then in FloatingActionButton remove:

 
.simultaneousGesture(
    TapGesture().onEnded {
        isPresented = true
    }
)

Now go to RecipeDetailsState.
Remove

 
    @Published private(set) var upload: KotlinBoolean? = nil

And replace

 
self.upload = upload
    if ((self.upload?.boolValue ?? false) == true){
        self.uploadSuccess = true
        self.isPresented = false
    }

with just

 
if ((upload?.boolValue ?? false) == true){
    self.uploadSuccess = true
    self.isPresented = false
}

Also in RecipeDetailState add several published values, which we’ll use in RecipeDetailView after:

 
    @Published var title: String = "" {
        didSet {
            viewModel.onTitleChanged(title: title)
        }
    }
    
    @Published var ingredients: [Ingredient] = [] {
        didSet {
            if let ingredient = ingredients.last {
            viewModel.onIngredientsChanged(ingredient: ingredient)
            }
        }
    }
    
    @Published var instructions: [Instruction] = [] {
        didSet {
            if let instruction = instructions.last {
            viewModel.onInstructionsChanged(instruction: instruction)
            }
        }
    }
    
    @Published var image: UIImage? = nil {
        didSet {
            viewModel.onImageChanged(image: image ?? UIImage())
        }
    }

Now go to RecipeDetailView
First change init to

 
    init(id: Int64?, isPresented: Binding<Bool>, uploadSuccess: Binding<Bool> ) { [1]
        self.recipeId = id?.toKotlinLong()
        state = RecipeDetailState(recipeId: id?.toKotlinLong(), isPresented: isPresented, uploadSuccess: uploadSuccess)
    }

[1] I think it’s better to pass id as Int64? here and then convert it to KotlinLong? for the RecipeDetailState

Now in EditIngredients replace

 
    var ingredients: [Ingredient]?
    var viewModel: RecipeDetailsViewModel

with

 
    @Binding var ingredients: [Ingredient]

And change the AddButton to

 
AddButton {
        ingredients.append(Ingredient(id: 0, name: name, amount: amount, metric: metric))
        name = ""
        amountString = ""
        metric = ""
    }.disabled(!isValid)
     .padding()

Do the same for EditInstrucitons:
Change

 
    var instructions: [Instruction]?
    var viewModel: RecipeDetailsViewModel

to

 
    @Binding var instructions: [Instruction]

Add

 
private var isValid: Bool {
    return description != ""
}

So that the add instruciton plus button is active only when the user types an instruction. Use it in body:

 
 var body: some View {
        
        Instructions(instructions: instructions)
        
        HStack {
            Text ("\(instructions.count  + 1). ")
            TextField("Description", text: $description)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .font(.body)
        
        AddButton {
            instructions.append(Instruction(id: 0, order: Int32(instructions.count + 1), description: description))
            description = ""
        }.disabled(!isValid)
            .padding()
    }

As you can see, we no longer directly call onChanged methods in RecipeDetailView. Instead, we hid those in RecipeDetailState and used @Published to manipulate them from RecipeDetailView.

Now in RecipeDetailView replace

 
                RecipePlaceholderView(image: Binding(
                    get: { state.recipe?.localImage},
                    set: {
                        if let image = $0 {
                            state.viewModel.onImageChanged(image: image)
                        }
                    }
                )).padding(16)

with just

 
RecipePlaceholderView(image: $state.image).padding(16)

Replace

 
TextField("What is the name of this dish?", text: Binding(
    get: { state.recipe?.title ?? "" },
    set: { state.viewModel.onTitleChanged(title: $0) }
    ))
    .multilineTextAlignment(.center)
    .padding()

with

 
TextField("What is the name of this dish?", text: $state.title)
    .multilineTextAlignment(.center)
    .padding()

Then replace

 
EditIngredients(ingredients: state.recipe?.ingredients, viewModel: state.viewModel )

with

 
EditIngredients(ingredients: $state.ingredients)

And finally replace

 
EditInstructions(instructions: state.recipe?.instructions, viewModel: state.viewModel )

with

 
EditInstructions(instructions: $state.instructions)

As you can see, now the body looks leaner and you use binding in RecipeDetailState to save changes in the shared view model:

 
 var body: some View {
        ScrollView {
            if (state.recipe?.images.isEmpty == false){
                KFImage(URL(string: state.recipe?.images.first?.image ?? ""))
                    .resizable()
                    .frame(width: 200, height: 150)
            } else {
                RecipePlaceholderView(image: $state.image).padding(16)
            }
            
            if (recipeId != nil) {
                Text(state.recipe?.title ?? "")
                    .font(.headline)
                    .padding()
            } else {
                TextField("What is the name of this dish?", text: $state.title)
                    .multilineTextAlignment(.center)
                    .padding()
            }
            
            Text("Ingredients")
                .font(.subheadline)
            if (recipeId != nil) {
                Ingredients(ingredients: state.recipe?.ingredients)
            } else {
                EditIngredients(ingredients: $state.ingredients)
            }
            
            Text("Instructions")
                .font(.subheadline)
            if (recipeId != nil) {
                Instructions(instructions: state.recipe?.instructions)
            } else {
                EditInstructions(instructions: $state.instructions)
            }
            
            if (recipeId == nil) {
                Button("Save recipe") {
                    state.saveRecipe()
                }
                .buttonStyle(FilledButtonStyle())
                .padding()
            }
            
        }.padding()
    }

Build and run the app. It still works just like before:

pizza app