swiftuiswiftdata

View with Images And TextField lag


In our macOS SwiftUI app, we are experiencing some lag when typing into a TextField in a view that also contains Images. It's not bad, but if a user types pretty quickly, they can get ahead of the TextField displaying the typed characters.

We've stepped through the code, used Instruments and can't seem to nail down the source of the lag.

The structure of the app is straightforward: There's a top level Content view with a list of people, upon clicking a person, a EditPersonView displays that persons name (or some string) along with some (3) pictures they had previously selected from their Photos library. The persons name is a single TextField which is where we are seeing lag when typing quickly

The image data is stored in SwiftData but are added to the View in .onAppear and stored in an array of Images. That avoids doing the loading/conversion when the view redraws itself.

Edit to include complete code per a comment:

import SwiftUI
import SwiftData

@Model
class Person {
    var name: String
    @Attribute(.externalStorage) var photos: [Data] = []

    init(name: String, emailAddress: String, details: String, metAt: Event? = nil) {
        self.name = name
    }
}

struct ContentView: View {
    @State private var path = NavigationPath()
    @Environment(\.modelContext) var modelContext
    @State private var searchText = ""
    @State private var sortOrder = [SortDescriptor(\Person.name)]
    @Query var people: [Person]
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(people) { person in
                    NavigationLink(value: person) {
                        Text(person.name)
                    }
                }
            }
            .navigationDestination(for: Person.self) { person in
                EditPersonView(person: person, navigationPath: $path)
            }
        }
    }

    func addPerson() {
        let person = Person(name: "New Person", emailAddress:"", details: "")
        modelContext.insert(person)
        path.append(person)
    }
}

#Preview {
    ContentView()
}

and then the EditPersonView

import PhotosUI
import SwiftData
import SwiftUI

struct EditPersonView: View {
    @Bindable var person: Person
    @Binding var navigationPath: NavigationPath
    @State private var pickerItems = [PhotosPickerItem]()
    @State private var selectedImages = [Image]()

    var body: some View {
        VStack {
            ScrollView(.horizontal, showsIndicators: true) {
                HStack(spacing: 8) {
                    ForEach(0..<selectedImages.count, id: \.self) { index in
                        selectedImages[index]
                            .resizable()
                            .scaledToFit()
                    }
                }
            }
            
            HStack {
                PhotosPicker("Select images", selection: $pickerItems, matching: .images, photoLibrary: .shared())
                Button("Remove photos") {
                    person.photos.removeAll()
                    selectedImages.removeAll()
                    pickerItems.removeAll()
                }
            }

            TextField("Name", text: $person.name)
                .textContentType(.name)

        }
        .navigationTitle("Edit Person")
        .navigationDestination(for: Event.self) { event in
            //show some info about the event. unrelated to issue
        }
        .onChange(of: pickerItems, addImages)
        .padding()
        .onAppear {
            person.photos.forEach { photoData in
                let nsImage = NSImage(data: photoData)
                let image = Image(nsImage: nsImage!)
                selectedImages.append(image)
            }
        }
    }

    func addImages() {
        Task {
            selectedImages.removeAll()
            
            for item in pickerItems {
                if let loadedImageData = try await item.loadTransferable(type: Data.self) {
                    person.photos.append(loadedImageData)
                    let nsImage = NSImage(data: loadedImageData)
                    let image = Image(nsImage: nsImage!)
                    selectedImages.append(image)
                }
            }
        }
    }
}

#Preview {
    do {
        let previewer = try Previewer()

        return EditPersonView(
            person: previewer.person,
            navigationPath: .constant(NavigationPath())
        )
        .modelContainer(previewer.container)
    } catch {
        return Text("Failed to create preview: \(error.localizedDescription)")
    }
}

and for completeness, here's Previewer

import Foundation
import SwiftData
import SwiftUICore

    @MainActor
    struct Previewer {
        let container: ModelContainer
        let event: Event
        let person: Person
        let ship: Image
        
        init() throws {
            let config = ModelConfiguration(isStoredInMemoryOnly: true)
            container = try ModelContainer(for: Person.self, configurations: config)
            
            event = Event(name: "Preview Event", location: "Preview Location")
            person = Person(name: "Preview Name", emailAddress: "preview@preview.com", details: "", metAt: event)
            ship = Image("Enterprise") //any image for the preview
            
            container.mainContext.insert(person)
        }
    }

and the main app entry point

import SwiftUI
import SwiftData

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Person.self)
    }
}

Here's an example - the last piece of text 'ok' displays a full 1 second after the user typed it.

enter image description here

We've eliminated environmental issues as this occurs across multiple Macs in different environments. The issue duplicates on Mac Studio M1 Max, iMac M4 and MacBook Pro M1 Pro. All running Sequoia 15.3.2, XCode 16.2


Solution

  • Your TextField is updating the query with every letter you type, which seems to be reloading the image each time and thus creates a lag.

    As a solution, try to replace $person.name binding of the TextField with a local @State variable that you set in a task . Then update the binding when the user has finished typing (presses return) with onSubmit.

    Like this:

    import SwiftUI
    
    struct EditPersonView: View {
        @Bindable var person: Person
        @State private var name = "" // <- this
        @Binding var navigationPath: NavigationPath
        @State private var pickerItems = [PhotosPickerItem]()
        @State private var selectedImages = [Image]()
    
        var body: some View {
            VStack {
                ScrollView(.horizontal, showsIndicators: true) {
                    HStack(spacing: 8) {
                        ForEach(0..<selectedImages.count, id: \.self) { index in
                            selectedImages[index]
                                .resizable()
                                .scaledToFit()
                        }
                    }
                }
                
                HStack {
                    PhotosPicker("Select images", selection: $pickerItems, matching: .images, photoLibrary: .shared())
                    Button("Remove photos") {
                        person.photos.removeAll()
                        selectedImages.removeAll()
                        pickerItems.removeAll()
                    }
                }
    
                TextField("Name", text: $name) // <- this
                    .textContentType(.name)
                    .onSubmit {
                        person.name = self.name
                    } // <- this
    
            }
            .navigationTitle("Edit Person")
            .navigationDestination(for: Event.self) { event in
                //show some info about the event. unrelated to issue
            }
            .onChange(of: pickerItems, addImages)
            .padding()
            .onAppear {
                person.photos.forEach { photoData in
                    let nsImage = NSImage(data: photoData)
                    let image = Image(nsImage: nsImage!)
                    selectedImages.append(image)
                }
            }
            .task {
                self.name = person.name // <- this
            }
        }
    
        func addImages() {
            Task {
                selectedImages.removeAll()
                
                for item in pickerItems {
                    if let loadedImageData = try await item.loadTransferable(type: Data.self) {
                        person.photos.append(loadedImageData)
                        let nsImage = NSImage(data: loadedImageData)
                        let image = Image(nsImage: nsImage!)
                        selectedImages.append(image)
                    }
                }
            }
        }
    }
    
    #Preview {
        do {
            let previewer = try Previewer()
    
            return EditPersonView(
                person: previewer.person,
                navigationPath: .constant(NavigationPath())
            )
            .modelContainer(previewer.container)
        } catch {
            return Text("Failed to create preview: \(error.localizedDescription)")
        }
    }