SwiftUI异常:UITextField获焦时操作视图致应用崩溃
问题现象
- 生产应用中100%可复现:当UITextField处于Focused状态时,修改视图(如触发Toggle开关、点击Cancel按钮)会直接导致应用崩溃。
操作流程
- 点击「AddCustomSource」按钮,显示文本输入框
- 让文本框获取焦点
- 点击Cancel按钮或触发任意Toggle开关,应用立即崩溃
控制台报错信息
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Attempt to delete item containing first responder that refused to resign.
First responder that was asked to resign (returned YES from -resignFirstResponder): <UITextField: 0x13411bc00; frame = (0 0; 290 22); opaque = NO; autoresize = W+H; gestureRecognizers = <NSArray: 0x600002d66880>; placeholder = NewspaperTitle; borderStyle = None; background = <UITextFieldNoBackgroundProvider: 0x6000021e5190: textfield=<UITextField 0x13411bc00>>; layer = <CALayer: 0x6000023568e0>> inside containing view: <SwiftUI.ListCollectionViewCell: 0x134151a00; baseClass = UICollectionViewListCell; frame = (20 82.6667; 358 44); clipsToBounds = YES; layer = <CALayer: 0x60000235d220>> at index path: <NSIndexPath: 0xae4395876dfb5f2e> {length = 2, path = 0 - 1}
Current first responder: <TtGC7SwiftUI15CellHostingViewGVS_15ModifiedContentVS_14_ViewList_ViewVS_26CollectionViewCellModifier: 0x134152000; frame = (0 0; 358 44); autoresize = W+H; gestureRecognizers = <NSArray: 0x600002d62f70>; layer = <CALayer: 0x60000235dc20>> inside containing view: <SwiftUI.ListCollectionViewCell: 0x134151a00; baseClass = UICollectionViewListCell; frame = (20 82.6667; 358 44); clipsToBounds = YES; layer = <CALayer: 0x60000235d220>> at index path: <NSIndexPath: 0xae4395876dfb5f2e> {length = 2, path = 0 - 1}'
terminating with uncaught exception of type NSException
CoreSimulator 857.14 - Device: iPhone 14 Pro Max (9E3E3FA5-4FCC-47D7-9D91-E1E5E48286FE) - Runtime: iOS 16.2 (20C52) - DeviceType: iPhone 14 Pro Max
相关代码
// // ContentView.swift // BLE // // Created by Entwicklung Help Tech GmbH on 22.12.22. // import SwiftUI import CoreBluetooth struct ContentView: View { @State var categories: [NewsSource] = [] @State var isEditing = false @State var newSourceName = "" var body: some View { VStack { List($categories, id:\.self ) { $category in Section { ForEach($category.sources, id: \.self ) { $source in Toggle(source.name, isOn: $source.active) .foregroundColor(Color.black) .accessibilityLabel(String(localized: "s")) .onChange(of: categories, perform: { newValue in }) .tint(Color.blue) if !isEditing { Button { [self] in withAnimation(.spring()) { isEditing.toggle() } } label: { HStack { Text(String(localized:"AddCustomSource")) } } } else { AddCell HStack { Button { [self] in withAnimation(.spring()) { //isFocused = false isEditing.toggle() } } label: { HStack { Text(String(localized: "Cancel")) } } Spacer() } } } } header: { Text(category.name) } } .padding() } .onAppear { for index in 1...10 { categories.append(NewsSource(name: "\(index)", feeds: [NewsFeed(name: "News feed \(index)", url: URL(string: "https://google.de")!, active: true)])) } } } @ViewBuilder var AddCell: some View { VStack { HStack { Button { [self] in if newSourceName != "" { withAnimation(.spring()) { isEditing.toggle() } } } label: { Image(systemName: "plus") .background(.green) .clipShape(Circle()) .foregroundColor(.white) .frame(width: 20.0, height: 20.0) } TextField(String(localized: "NewspaperTitle"),text: $newSourceName) .onSubmit { [self] in if newSourceName != ""{ newSourceName = "" withAnimation(.spring()) { isEditing.toggle() } } } } } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } // // Newspaper.swift // Activator // // Created by Entwicklung Help Tech GmbH on 20.02.23. // struct NewsSource: Codable, Hashable, Identifiable, Equatable { var id: UUID { UUID() } var name: String var sources: [NewsFeed] init(name: String, feeds: [NewsFeed]) { self.name = name self.sources = feeds } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.name = try container.decode(String.self, forKey: .name) self.sources = try container.decode([NewsFeed].self, forKey: .sources) } func hash(into hasher: inout Hasher) { hasher.combine(name) } static func ==(lhs: NewsSource, rhs: NewsSource) -> Bool { return lhs.name == rhs.name && lhs.sources == rhs.sources } } struct Newspaper: Codable, Hashable { var categories : [NewsSource] } struct NewsFeed: Codable, Equatable, Hashable, Identifiable { var id: UUID? { UUID() } let name: String let url: URL var active: Bool var image: String? }
解答
这不是预期情况,属于SwiftUI与UIKit交互时的焦点管理冲突:当List的Cell包含第一响应者(此处为UITextField)时,修改视图结构(如切换isEditing导致Cell内容变化、或Toggle触发数组更新)会触发List的Cell复用/重排,此时系统尝试让第一响应者 resign,但由于SwiftUI视图更新机制与UIKit焦点管理存在时序冲突,最终引发崩溃。
必须做防御性开发,推荐以下解决思路:
方案1:手动绑定并管理文本框焦点
- 在
ContentView中添加焦点状态变量:@FocusState private var isTextFieldFocused: Bool - 给TextField绑定焦点状态,并在提交时主动取消焦点:
TextField(String(localized: "NewspaperTitle"),text: $newSourceName) .focused($isTextFieldFocused) .onSubmit { if newSourceName != ""{ newSourceName = "" isTextFieldFocused = false withAnimation(.spring()) { isEditing.toggle() } } } - 在所有触发视图修改的操作前取消焦点,比如Cancel按钮:
Button { isTextFieldFocused = false withAnimation(.spring()) { isEditing.toggle() } } label: { HStack { Text(String(localized: "Cancel")) } }
方案2:避免在List内部动态切换视图结构
当前代码在ForEach的每个Item内部根据isEditing切换不同视图,这种动态变化会导致List的Cell布局频繁变动,容易触发焦点冲突。可以将添加源的UI移到List外部,或用Sheet/FullScreenCover展示,避开List的Cell复用逻辑。
方案3:全局强制取消第一响应者
定义全局方法,在视图修改前强制取消当前第一响应者:
func resignFirstResponderGlobally() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) }
在Cancel按钮、Toggle操作前调用该方法:
Button { resignFirstResponderGlobally() withAnimation(.spring()) { isEditing.toggle() } } label: { /* ... */ }
补充说明
这类问题属于SwiftUI对UIKit控件焦点管理的边界Bug,生产环境必须修复,否则会严重影响用户体验,不属于预期行为。
内容的提问来源于stack exchange,提问作者Daniel Jorge

