最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

swiftui - View with Images And TextField lag - Stack Overflow

programmeradmin2浏览0评论

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: "[email protected]", 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.

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

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: "[email protected]", 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.

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

Share Improve this question edited Apr 1 at 19:22 soundflix 2,86312 gold badges16 silver badges34 bronze badges asked Mar 30 at 14:12 JayJay 35.7k19 gold badges59 silver badges88 bronze badges 8
  • I'd suggest using Instruments.app to figure out where the problem is. The current code you showed is not really a minimal reproducible example. – Sweeper Commented Mar 30 at 14:21
  • @Sweeper Perhaps it was overlooked but as mentioned in the question, we used Instruments and it was not revealing. Adding a Person Model and a simple Content view makes the code runnable so updated the question what that. I moved the Scrollview code into the body as well for brevity – Jay Commented Mar 30 at 15:54
  • Turn on CoreDate debug logging and use let _ = Self._printChanges() in both views and you will see that each typed character in the text field fires an update of the Query in the ContentView. You need to separate the two views so you don't use @Bindable – Joakim Danielson Commented Mar 30 at 17:38
  • @JoakimDanielson Wow - that's really interesting, thanks for catching it. When you say "separate the two views" - which two views? Does my code match your answer to this question which uses @Query and @Bindable? My code separates the @Query macro in ContentView (like your ContentView) and @Bindable in the EditPersonView, like your ItemRow view. Or am I misunderstanding? – Jay Commented Mar 31 at 20:30
  • I mean if you don’t use Bindable then changes in the text field will not fire the query to be updated. Normally this might not be much of an issue but now it probably means that all the image data gets loaded over and over again. You could easily test this by let declare the Person property and instead work with a local @State property for the name just to see if the performance problem goes away. – Joakim Danielson Commented Mar 31 at 20:41
 |  Show 3 more comments

2 Answers 2

Reset to default 1

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)")
    }
}

In the question, the code provides a somewhat automated experience for the user where if they change the text, it's auto-saved. The wonderful answer from @soundflix provides a solution but after implementing that, there's another option to provide a similar experience: save the changes when the user clicks or taps the back < button.

From the answer I omitted the onSubmit code

.onSubmit {
    person.name = self.name
}

and then crafted my own toolbar "back" button that saves the string when the user clicks back

VStack {
   //
}
.navigationBarBackButtonHidden(true) //hide the default back button
.toolbar {
    ToolbarItem(placement: ToolbarItemPlacement.navigation) {
        Button {
            person.name = self.name
            navigationPath.removeLast()
        } label: {
            Image(systemName: "chevron.left")
        }
    }
}
发布评论

评论列表(0)

  1. 暂无评论