skip to Main Content

I’m experiencing a confusing issue in SwiftUI where Images on a simple VStack card flash during interaction. See the video below:

Short video demonstrating the problem

I’ve split this out from the app I’m working on into a simpler sample project. Essentially, there’s a simple button that loads the PhotoPicker and adds photos to state. Each photo card uses an ObservableObject to track whether it is selected–this gets toggled on tap to ensure only a single card is selected at a time.

class EntriesState : ObservableObject {
    @Published var selectedIndex : Int? = nil
}

struct PhotoView : View {
    var image : Data;
    var index : Int;
    @ObservedObject var entriesState: EntriesState
    
    
    var body: some View {
                        VStack {
                            if let uiImage = UIImage(data: image) {
                                Image(uiImage: uiImage)
                                    .resizable()
                                    .scaledToFit()
                                    .clipShape(RoundedRectangle(cornerRadius: 24.0))
                                    .onAppear() {
                                        print("Image (index) appeared")
                                    }
                            }
                            if entriesState.selectedIndex == index {
                                Text("This is the description for the image")
                            }
                        }
                        .padding(20)
                        .background(Color.blue)
                        .clipShape(RoundedRectangle(cornerRadius: 24.0))
                        .onTapGesture {
                            // Removing `withAnimation` fixes the flashing,
                            // but removes the animation.
                            
                            // Is there a way to have the nice animation
                            // as the card expands, without the image flashing?
                            withAnimation {
                                if entriesState.selectedIndex == index {
                                    entriesState.selectedIndex = nil
                                } else {
                                    entriesState.selectedIndex = index
                                }
                            }
                        }
        
    }
}

struct ContentView: View {
    @State private var selectedItems = [PhotosPickerItem]()
    @State private var selectedImages = [Data]()
    @StateObject var entriesState = EntriesState()

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 200))]) {
                    ForEach(0..<selectedImages.count, id: .self) { i in
                        PhotoView(image: selectedImages[i], index: i, entriesState: entriesState)
                            .padding(20)
                    }
                }
            }
            .toolbar {
                PhotosPicker("Select images", selection: $selectedItems, matching: .images)
            }
            .onChange(of: selectedItems) {
                Task {
                    selectedImages.removeAll()

                    for item in selectedItems {
                        if let data = try? await item.loadTransferable(type: Data.self) {
                            selectedImages.append(data)
                           
                        }
                    }
                }
            }
        }
    }
}

I’ve tried:

  1. Using @Binding instead of @ObservableObject to track the selected index.
  2. Adding .id(index) to the image
  3. Tracking .onAppear calls for the image–I’m only seeing that called once, not multiple times like I would expect if the image were being rerendered.
  4. Removing the animation. This fixes the problem, but I’d like to be able to animate the growing/shrinking of the card
  5. Using @State var selected in the PhotoView. This prevents the flashing on ALL images, but the image still flashes on the selected card. See here
  6. Searching StackOverflow and Google for solutions. Notably, I’ve found people with similar problems here and here but nothing suggested has worked 🙁

Please let me know if you have any suggestions or questions! Thanks!

2

Answers


  1. Chosen as BEST ANSWER

    I found a solution! Here is a video of what it looks like now.

    The solution is pretty simple--instead of storing the image data in the view, store the UIImage directly. My guess is the flash comes from converting the Data -> UIImage during the animation as for a frame or two there might be nothing to show.

    import SwiftUI
    import PhotosUI
    
    class EntriesState : ObservableObject {
        @Published var selectedIndex : Int? = nil
    }
    
    struct PhotoView : View {
        var image : Data
        var index : Int
        @ObservedObject var entriesState: EntriesState
        
        var uiImage : UIImage? = nil // Use the UIImage itself
        
        init(image: Data, index: Int, entriesState: EntriesState) {
            self.image = image
            self.index = index
            self.entriesState = entriesState
    
            // Convert the image data into a UIImage, store it in the view
            if let uiImage = UIImage(data: image) {
                self.uiImage = uiImage
            }
        }
        
        
        var body: some View {
                            VStack {
                                if let uiImage = self.uiImage{
                            Image(uiImage: uiImage)
                                            .resizable()
                                            .scaledToFit()
                                            .clipShape(RoundedRectangle(cornerRadius: 24.0))
                                            .id(index)
                                            .onAppear() {
                                                print("Image (index) appeared")
                                            }
                                }
                                if entriesState.selectedIndex == index {
                                    Text("This is the description for the image")
                                }
                            }
                            .padding(20)
                            .background(Color.red)
                            .clipShape(RoundedRectangle(cornerRadius: 24.0))
                            .onTapGesture {
                                withAnimation {
                                    entriesState.selectedIndex = entriesState.selectedIndex == index ? nil : index
                                }
                            }
            
        }
    }
    
    struct ContentView: View {
        @State private var selectedItems = [PhotosPickerItem]()
        @State private var selectedImages = [Data]()
        @State private var entriesState = EntriesState()
    
        var body: some View {
            NavigationStack {
                ScrollView {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 200))]) {
                        ForEach(0..<selectedImages.count, id: .self) { i in
                            PhotoView(image: selectedImages[i], index: i, entriesState: entriesState)
    
                                .padding(20)
                        }
                    }
                }
                .toolbar {
                    PhotosPicker("Select images", selection: $selectedItems, matching: .images)
                }
                .onChange(of: selectedItems) { _ in
                    Task {
                        selectedImages.removeAll()
    
                        for item in selectedItems {
                            if let data = try? await item.loadTransferable(type: Data.self) {
                                selectedImages.append(data)
                               
                            }
                        }
                    }
                }
            }
        }
    }
    

  2. I see that you already tried .onAppear() method but, my best guess is that the flashing happens because of the .resizable() modifier being applied to the Image view. As we know, this modifier recalculates the size of the image based on the new layout constraints during the animation. That may be the reason you’re facing this issue. In order to handle this, .onAppear() can be used. Among other things.

    I’ve looked through the links you provided and came up with solution, hope that works out for you.

    We can introduce a separate state variable to track the selected state of each PhotoView and we can avoid modifying the entriesState.selectedIndex directly during the animation. This separation should prevent the recalculation of the Image view’s size and as a result, prevents the flash effect.

    struct PhotoView : View {
        var image : Data;
        var index : Int;
        @ObservedObject var entriesState: EntriesState
        @State private var selected: Bool = false // here is our new state
    
        // rest of your code here
    }
    

    Now let’s modify the .onAppear() in the Image view to set the initial value of selected based on the selectedIndex.

    .onAppear() {
        selected = entriesState.selectedIndex == index
    }
    

    After that, we can update the onTapGesture to toggle the selected state instead of directly modifying entriesState.selectedIndex.

    .onTapGesture {
        withAnimation { // also using withAnimation, ive seen it from the reddit post
            selected.toggle()
        }
    }
    

    Finally, update the if condition in the VStack to use the selected state.

    if isSelected {
        Text("This is the description for the image")
    }
    

    With these changes, the image should animate smoothly without any flashes when tapped. Hope that solutions works for you.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search