You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI列表与表单更新时的TextField光标跳转问题

光标在TextField编辑时跳转到末尾的问题排查与修复

问题场景

基于Apple官方教程改编的SwiftUI应用采用左右分栏布局:左侧展示食谱列表,右侧为选中食谱的编辑表单,其中编辑食谱名称的TextField绑定至config.recipe.name状态变量。但在文本中间编辑内容时,光标会意外跳转到字段末尾,严重影响编辑体验。

原始代码

import SwiftUI

struct Recipe: Identifiable, Hashable {
    var id: UUID = UUID()
    var name: String
    
    static var emptyRecipe: Recipe {
        Recipe(name: "Empty Recipe")
    }
}

struct RecipeEditorConfig {
    var recipe = Recipe.emptyRecipe
    var originalRecipe = Recipe.emptyRecipe
    var shouldSaveChanges = false
    var isPresented = false
    
    mutating func presentEditRecipe(_ recipeToEdit: Recipe) {
        originalRecipe = recipeToEdit
        recipe = recipeToEdit
        
        shouldSaveChanges = false
        isPresented = true
    }
    
    mutating func done() {
        originalRecipe = recipe
        shouldSaveChanges = true
        isPresented = false
    }
    
    var modified: Bool {
        return recipe != originalRecipe
    }
    
    mutating func save(to recipes: inout [Recipe]) {
        if let index = recipes.firstIndex(where: {$0.id == recipe.id}) {
            recipes[index] = recipe
        }
    }
}

struct ContentListView: View {
    @State var selectedRecipeID: Recipe.ID?
    @State var config: RecipeEditorConfig = RecipeEditorConfig()
    @State var listedRecipes: [Recipe] = [
        Recipe(name: "Apple Pie"),
        Recipe(name: "Vanilla Icecream")
    ]
    var body: some View {
        HSplitView {
            List(selection: $selectedRecipeID) {
                ForEach(listedRecipes) { recipe in
                    Text(recipe.name)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    
            ZStack {
                if config.isPresented {
                    Form {
                        Section("Recipe") {
                            TextField("Name", text: $config.recipe.name)
                        }
                        Button {
                            config.done()
                        } label: {
                            Text("Save")
                        }
                        .disabled(!config.modified)
                    }
                    .formStyle(.grouped)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)

        }
        // Save changes
        .onChange(of: config.shouldSaveChanges) { shouldSave in
            if shouldSave {
                config.save(to: &listedRecipes)
            }
        }
        // Show recipe editor for the selected recipe
        .onChange(of: selectedRecipeID) { selectedID in
            if let id = selectedID {
                let recipe = listedRecipes.first(where: {$0.id == id})
                config.presentEditRecipe(recipe!)
            } else {
                config.isPresented = false
            }
        }
        .onAppear {
            // If there's no selection but we have recipes, select the first one
            if selectedRecipeID == nil && !listedRecipes.isEmpty {
                selectedRecipeID = listedRecipes.first?.id
            }
        }
    }
}

#Preview {
    ContentListView()
}

复现步骤

  • 点击“Apple Pie”;
  • 在名称文本中间输入内容,例如在“Apple”后添加“Cream”;
  • 输入时光标会跳转到字段末尾。

无效尝试

曾将name变量单独移至ContentListView中,但问题未解决且代码结构混乱。


问题原因

光标跳转的核心原因是**RecipeEditorConfig是值类型(struct)**:当修改config.recipe.name时,整个config结构体被重新创建(值类型的特性),导致绑定的TextField所在视图被强制重渲染,光标位置丢失并重置到末尾。


修复方案

将RecipeEditorConfig从值类型改为引用类型(class),并添加状态监听标记,确保修改内部属性时不会触发整个对象的重建,视图仅更新必要部分,从而保留光标位置。

修复后的完整代码

import SwiftUI

struct Recipe: Identifiable, Hashable {
    var id: UUID = UUID()
    var name: String
    
    static var emptyRecipe: Recipe {
        Recipe(name: "Empty Recipe")
    }
}

// iOS 17+ 使用@Observable,iOS 16及以下改用ObservableObject+@Published
@Observable
class RecipeEditorConfig {
    var recipe = Recipe.emptyRecipe
    var originalRecipe = Recipe.emptyRecipe
    var shouldSaveChanges = false
    var isPresented = false
    
    func presentEditRecipe(_ recipeToEdit: Recipe) {
        originalRecipe = recipeToEdit
        recipe = recipeToEdit
        
        shouldSaveChanges = false
        isPresented = true
    }
    
    func done() {
        originalRecipe = recipe
        shouldSaveChanges = true
        isPresented = false
    }
    
    var modified: Bool {
        return recipe != originalRecipe
    }
    
    func save(to recipes: inout [Recipe]) {
        if let index = recipes.firstIndex(where: {$0.id == recipe.id}) {
            recipes[index] = recipe
        }
    }
}

struct ContentListView: View {
    @State var selectedRecipeID: Recipe.ID?
    // 使用@StateObject管理引用类型的配置对象
    @StateObject var config = RecipeEditorConfig()
    @State var listedRecipes: [Recipe] = [
        Recipe(name: "Apple Pie"),
        Recipe(name: "Vanilla Icecream")
    ]
    var body: some View {
        HSplitView {
            List(selection: $selectedRecipeID) {
                ForEach(listedRecipes) { recipe in
                    Text(recipe.name)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    
            ZStack {
                if config.isPresented {
                    Form {
                        Section("Recipe") {
                            TextField("Name", text: $config.recipe.name)
                        }
                        Button {
                            config.done()
                        } label: {
                            Text("Save")
                        }
                        .disabled(!config.modified)
                    }
                    .formStyle(.grouped)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)

        }
        // Save changes
        .onChange(of: config.shouldSaveChanges) { shouldSave in
            if shouldSave {
                config.save(to: &listedRecipes)
            }
        }
        // Show recipe editor for the selected recipe
        .onChange(of: selectedRecipeID) { selectedID in
            if let id = selectedID {
                let recipe = listedRecipes.first(where: {$0.id == id})
                config.presentEditRecipe(recipe!)
            } else {
                config.isPresented = false
            }
        }
        .onAppear {
            // If there's no selection but we have recipes, select the first one
            if selectedRecipeID == nil && !listedRecipes.isEmpty {
                selectedRecipeID = listedRecipes.first?.id
            }
        }
    }
}

#Preview {
    ContentListView()
}

关键修改点

  1. 将RecipeEditorConfig改为class:引用类型修改内部属性时不会创建新对象,避免视图不必要的重渲染。
  2. 添加状态监听标记:
    • iOS 17+ 使用@Observable自动追踪属性变化;
    • iOS 16及以下改用ObservableObject基类,并给需要监听的属性添加@Published标记:
      class RecipeEditorConfig: ObservableObject {
          @Published var recipe = Recipe.emptyRecipe
          @Published var originalRecipe = Recipe.emptyRecipe
          @Published var shouldSaveChanges = false
          @Published var isPresented = false
          // 其余方法不变
      }
      
  3. 用@StateObject管理配置对象:确保视图生命周期内对象的唯一性,避免重复初始化。

修改后,编辑TextField时仅更新文本内容,不会触发整个表单视图的重建,光标位置可正常保留。


内容的提问来源于stack exchange,提问作者Captain_Apollo

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 10:39:52