fuxlud / Modularized_iOS_App

24 stars 2 forks source link

Not vanilla SwiftUI #3

Closed malhal closed 1 week ago

malhal commented 2 weeks ago

Hi I found this via iOS dev weekly. Try removing the legacy view model object and using multiple View structs and state/binding for your view data, that's vanilla SwiftUI.

fuxlud commented 2 weeks ago

Hi @malhal Thank you so much for taking the time to go over the code. I meant vanilla SwiftUI in the sense that I'm using the SwiftUI framework in its most basic or standard form, without any additional libraries, extensions, or modifications. Default SwiftUI components and capabilities provided by Apple, as opposed to using third-party tools.

fuxlud commented 1 week ago

I'll close this for now, but thank you for taking the time to write.

malhal commented 4 days ago

Most of what I'm going to say is covered in Data Essentials in SwiftUI where Curt explains the view model (i.e. View struct) and Luka covers the data model (i.e. ObservableObject - now Observable).

First thing in your sample code I notice is:

var favoriteButton: some View {

In SwiftUI you really need a body because a lot of magic goes on inside it including dependency tracking of both view data and model data, so to fix that just make a new subview:

struct FavoriteButton: View {

    var body: some View {

Now we have the magic body we can now tackle answering the question Curt asks in the video: "What data does this View need to do its job?". If it just needed read access to something it would be let isFavorite, if needed write access to something it would be @Binding var isFavorite (Binding is just a struct with get and set closures). The important thing to note is body is called in either case if the isFavorite value changes from the last time the View was init and supplied this param (e.g. FavouriteButton(isFavourite: something.favourite)), even in the let case which is very unintuitive and probably the main reason the developers make the mistake of going down the view model object route instead of just learning body. Heck even Stanford university made this mistake in their lectures! It might be helpful for you to think of body like an onChange of everything declared in the View. Since you need write access go with:

@Binding var isFavorite: Bool

Now we can write the Button code correctly since now we have only the data we need. e.g. something like

Button(action: {
                   isFavorite.toggle()  // viewModel.likeButtonTapped()
                }) {
                     Image(systemName: isFavourite ? "heart.fill" : "heart")
                    }
                    .font(.system(size: 28, weight: .medium))
                    .foregroundColor(.white)
                }

You should now notice the improvement, now body is only called when isFavourite is changed, previously yours was being called on any change to a property of viewModel, e.g. imageUrl and in fact because you didn't have a subview this Button was being re-init on changes to any other properties in the BreedImageView like tileSize.

Now to answer Curt's next question of "Where does the data come from?", it comes from somewhere else which is why we use the binding, so we can init the custom View like:

FavoriteButton(isFavourite: $somethingElse.isFavourite)

$ is just sugar for .projectedValue which usually computes a Binding(get: set:) pair of closures and somethingElse can either be view data, e.g. a struct in a State, or it can be model data, e.g. a struct in an object where the object loads/saves the model structs. In your case it comes from Breed model struct, and is part of the flow for viewing details of an existing breed, so we'll do something like:

FavoriteButton(isFavourite: $breed.isFavourite)

If on the other hand the this button is being used for a flow creating a new breed, e.g. in a model form, then the breed might be State and it would be something like:

@State var newBreed = Breed()
...
FavoriteButton(isFavourite: $newBreed.isFavourite)

Since FavoriteButton can work with a binding to a bool of anything, it can be reused for both model data and view data. If the model data was not a bool but an enum then we can transform it using a computed binding, see below.

The other important thing in the video is when Luka takes over and he says something along the lines of "Curt talked about view data...what about model data...this is when you reach a critical point...for model data you should use ObservableObject". So knowing this should help you stick to @State/@Binding for your view data and leave objects for your model store.

Some other useful things to know is to transform model data to simple types use computed vars. You can also compute bindings when you need to transform back from simple types to your model types. But overall just keep all your view data and transformations inside the View struct, that way body will behave correctly like an onChange. Here is an example of a computed binding where the model uses an int instead of a bool:

        @Binding breed: Breed

        var isFavourite: Binding<Bool> {
            get {
                Binding {
                    breed.rating == 1
                } set: { newValue in
                    breed.rating = newValue ? 1 : 0
                }
            }
        }

It might also be useful to know that View structs are actually diffed on every change, and the delta is used to init/update/deinit UIKit objects. This might have not been the best idea but it's what we have.