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 | Show 3 more comments2 Answers
Reset to default 1Your 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")
}
}
}
let _ = Self._printChanges()
in both views and you will see that each typed character in the text field fires an update of theQuery
in theContentView
. You need to separate the two views so you don't use@Bindable
– Joakim Danielson Commented Mar 30 at 17:38@Query
and@Bindable
? My code separates the@Query
macro inContentView
(like your ContentView) and@Bindable
in theEditPersonView
, like yourItemRow
view. Or am I misunderstanding? – Jay Commented Mar 31 at 20:30let
declare thePerson
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