I'm picking the photos from the PHPicker and displaying them in the VStack
using the ForEach
loop in SwiftUI. I'm wrapping the image in an Object with 3 properties.
class UploadedImage: Hashable, Equatable {
var id: Int
var image : UIImage
@State var quantity: Int
init(id: Int, image: UIImage, quantity: Int) {
self.id = id
self.image = image
self.quantity = quantity
}
static func == (lhs: UploadedImage, rhs: UploadedImage) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
}
Then I'm adding that object in the array after the picker select the images, and then displaying on the screen using the ForEach.
if !uploadedImages.isEmpty {
ScrollView {
VStack{
ForEach(uploadedImages, id: \.self) { selectedImage in
HStack{
ZStack{
// some other code to display image...
}
Spacer()
HStack{
Button(action: {
if let index = self.uploadedImages.firstIndex(where: {$0.id == selectedImage.id}){
uploadedImages[index] = UploadedImage(id: selectedImage.id, url: selectedImage.url, quantity: (1))
}
}, label: {
Text("-")
})
Text("\(selectedImage.quantity)")
Button(action: {
if let index = self.uploadedImages.firstIndex(where: {$0.id == selectedImage.id}){
uploadedImages[index] = UploadedImage(id: selectedImage.id, url: selectedImage.url, quantity: selectedImage.quantity 1)
}
}, label: {
Text(" ")
})
}
}
.padding()
.border(Color.black, width: 2)
.cornerRadius(6)
}
}
}
}
}
In the ForEach loop, I'm showing two buttons, , -
to increase and decrease the quantity.
On that button the action is the following code.
if let index = self.uploadedImages.firstIndex(where: {$0.id == selectedImage.id}){
uploadedImages[index] = UploadedImage(id: selectedImage.id, url: selectedImage.url, quantity: selectedImage.quantity 1)
}
But the quantity is not updating. I'm not sure why.
When I change the id
in the new updated Object.
uploadedImages[index] = UploadedImage(id: 89282, url: selectedImage.url, quantity: selectedImage.quantity 1)
Then the quantity is updating only once.
Any good solution to increase and decrease the quantity?
CodePudding user response:
Data flow in SwiftUI can be a bit tricky to get used to initially, depending on what framework you’re coming from.
There are various ways to approach it from high-level perspectives, but given that your question is about this localized issue of a single View, its model, and a button, I will focus on data from a single View’s perspective.
To then understand how data flows between Views, how it interacts with SwiftUI’s View lifecycle, and more, I would recommend these two WWDC sessions:
There’s also this document from Apple with a decent breakdown: State and Data Flow
Returning to the View’s perspective. The way you set up your data inside a View will be quite different based on two characteristics of your data:
- Is the data a value type or a reference type?
- Do you want SwiftUI to handle the lifecycle of your data or do you want to manage it yourself?
So on the simple end of that matrix, you have a value type that you let SwiftUI handle for you. This is what @State is for.
Inside a View, you might use @State like this to deal with a string:
@State var name = “Bob”
but a struct would work as well:
@State var selectedUser = MyUserStruct()
The important thing to know about @State is that it is explicitly meant for this simple end of the possible scenarios.
@State
- only works within Views
- needs the data to be a value type to function properly
- is bound to the lifecycle of the View
It’s really mostly for data that represents state internal to the view. Even data that was passed in from the parent and then is mostly the child View’s business, is not easily mappable to @State.
Note, that nothing has to be done to our data in this case, to make SwiftUI understand when to update the View. (At least for most use cases.)
Moving up in complexity: What if you want SwiftUI to handle the data, but you are dealing with a reference type? This is the setup you currently have.
In contrast to the value type example, SwiftUI needs additional help understanding when to update the Views that depend on reference types.
Therefore, any classes that should drive View updates need to conform to the ObservableObject protocol. Further, within those classes, only properties marked @Published will actually drive View updates when those properties change.
To then use an @ObservableObject inside a View, you have a choice of a couple of property wrappers with very different behaviors: @StateObject, @ObservedObject, and @EnvironmentObject
@StateObject is the closest in its behavior to @State. It is owned by the View, and the lifecycle of your object is bound to the View's lifecycle. The other two, don’t match our current location in the matrix of complexity, as they are meant for the case in which you don’t want SwiftUI to handle your data’s lifecycle (or not as directly).
But this actually gives us sufficient information to restructure your example and stay within our single View perspective.
Let’s first take your example literally and pretend that we just want to make those localized updates in an array, and that’s its. For this case, @State would actually work, but we will need to change your UploadedImage model to a struct:
struct UploadedImage: Identifiable {
let id: Int
let image: Image
var quantity: Int
}
Two notes:
- I changed your image to use SwiftUI’s Image type instead of UIKit’s UIImage. The latter would still work for the purpose of this example if you absolutely need to deal with UIImage instances.
- Identifiable is a protocol that tells SwiftUI to use the id property (which you already had anyways) to identify data in places like ForEach. Therefore, we don’t have to conform Hashable anymore, nor do we need to tell the ForEach explicitly how to identify each entity.
Now we’ll use this in the View, via @State:
@State var uploadedImages = [
UploadedImage(id: 1, image: someImage, quantity: 4),
UploadedImage(id: 2, image: someImage, quantity: 8),
UploadedImage(id: 3, image: someImage, quantity: 16)
]
And as mentioned above, the ForEach can be simplified to:
ForEach(uploadedImages) { selectedImage in
// ...
}
And that’s actually all that is needed to make your example work.
But as others have pointed out, it’s likely that you may have other logic to handle, and it would potentially be nice to split out some of this non-UI logic to a separate view model, have the View interact with that model, and observe it for changes.
Usually, view models take the form of a class, so we change our approach to the reference type route we discussed above: our view model will need to conform to ObservableObject, use @Published to surface any data that should drive updates, and it gets handed to SwiftUI to manage via @StateObject.
Combined, this solution looks like this:
@MainActor class UploadedImagesViewModel: ObservableObject {
@Published var images = [
UploadedImage(id: 3, image: someImage, quantity: 4),
UploadedImage(id: 4, image: someImage, quantity: 8),
UploadedImage(id: 5, image: someImage, quantity: 16)
]
func increment(_ selectedImage: UploadedImage) {
if let index = self.images.firstIndex(where: {$0.id == selectedImage.id}){
images[index].quantity = 1
}
}
func decrement(_ selectedImage: UploadedImage) {
if let index = self.images.firstIndex(where: {$0.id == selectedImage.id}){
images[index].quantity -= 1
}
}
}
struct UploadedImage: Identifiable {
let id: Int
let image: Image
var quantity: Int
}
struct UploadedImagesView: View {
@StateObject var viewModel = UploadedImagesViewModel()
var body: some View {
if !$viewModel.images.isEmpty {
ScrollView {
VStack{
ForEach(viewModel.images) { selectedImage in
HStack{
ZStack{
// some other code to display image...
}
Spacer()
HStack{
Button(action: {
viewModel.decrement(selectedImage)
}, label: {
Text("-")
})
Text("\(selectedImage.quantity)")
Button(action: {
viewModel.increment(selectedImage)
}, label: {
Text(" ")
})
}
}
.padding()
.border(Color.black, width: 2)
.cornerRadius(6)
}
}
}
}
}
}
A few final final notes:
- As you can see, the actual data model remained in struct form. There are cases where you may actually want an array of other reference types, e.g. of other view models, but those cases are somewhat rare and hard to make work properly. This combination of a class for your ViewModel and a struct for the actual data model is the most common approach.
- @MainActor is something related to Swift’s new concurrency model, and the details are out of the scope of this answer. But it’s a good idea to apply to view models / ObservableObjects in general, as it will ensure (and via compiler errors guide you to) only update the ViewModel from the main thread. As of right now, using @MainActor would require you to target iOS 15 and its siblings, but Apple has started the beta cycle of Xcode 13.2, which includes backward support for Swift’s new concurrency model (as far back as iOS 13).