修复拼写页 AttributeGraph 无限递归闪退

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 21:35:25 +08:00
parent 7d123afe79
commit 86a4a3b168
@@ -1,11 +1,10 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
// Captures keystrokes one at a time via hidden UITextField delegate
struct KeystrokeCaptureField: UIViewRepresentable { struct KeystrokeCaptureField: UIViewRepresentable {
let onChar: (Character) -> Void let onChar: (Character) -> Void
let onBackspace: () -> Void let onBackspace: () -> Void
@Binding var isActive: Bool let startActive: Bool
func makeUIView(context: Context) -> UITextField { func makeUIView(context: Context) -> UITextField {
let tf = UITextField() let tf = UITextField()
@@ -13,6 +12,7 @@ struct KeystrokeCaptureField: UIViewRepresentable {
tf.autocorrectionType = .no tf.autocorrectionType = .no
tf.autocapitalizationType = .none tf.autocapitalizationType = .none
tf.spellCheckingType = .no tf.spellCheckingType = .no
context.coordinator.textField = tf
return tf return tf
} }
@@ -21,16 +21,17 @@ struct KeystrokeCaptureField: UIViewRepresentable {
} }
func updateUIView(_ uiView: UITextField, context: Context) { func updateUIView(_ uiView: UITextField, context: Context) {
if isActive { if startActive && !context.coordinator.didFocus {
uiView.becomeFirstResponder() uiView.becomeFirstResponder()
} else { context.coordinator.didFocus = true
uiView.resignFirstResponder()
} }
} }
class Coordinator: NSObject, UITextFieldDelegate { class Coordinator: NSObject, UITextFieldDelegate {
let onChar: (Character) -> Void let onChar: (Character) -> Void
let onBackspace: () -> Void let onBackspace: () -> Void
weak var textField: UITextField?
var didFocus = false
init(onChar: @escaping (Character) -> Void, onBackspace: @escaping () -> Void) { init(onChar: @escaping (Character) -> Void, onBackspace: @escaping () -> Void) {
self.onChar = onChar self.onChar = onChar
@@ -49,7 +50,6 @@ struct KeystrokeCaptureField: UIViewRepresentable {
} }
} }
// Blinking cursor on the active blank
struct BlinkingCursor: View { struct BlinkingCursor: View {
var body: some View { var body: some View {
TimelineView(.periodic(from: .now, by: 0.6)) { timeline in TimelineView(.periodic(from: .now, by: 0.6)) { timeline in
@@ -62,13 +62,11 @@ struct BlinkingCursor: View {
} }
} }
// Interactive word input with per-blank slots
struct SpellingWordInput: View { struct SpellingWordInput: View {
let blankedWord: String let blankedWord: String
let blankPositions: [Int] let blankPositions: [Int]
@Binding var currentBlankIndex: Int @Binding var currentBlankIndex: Int
@Binding var filledBlanks: [String] @Binding var filledBlanks: [String]
@State private var isFocused = false
private let chars: [String] private let chars: [String]
@@ -81,67 +79,59 @@ struct SpellingWordInput: View {
} }
var body: some View { var body: some View {
VStack(spacing: 8) { KeystrokeCaptureField(
KeystrokeCaptureField( onChar: { char in
onChar: { char in guard currentBlankIndex < blankPositions.count else { return }
guard currentBlankIndex < blankPositions.count else { return } filledBlanks[currentBlankIndex] = String(char).lowercased()
filledBlanks[currentBlankIndex] = String(char).lowercased() currentBlankIndex += 1
currentBlankIndex += 1 },
}, onBackspace: {
onBackspace: { guard currentBlankIndex > 0 else { return }
guard currentBlankIndex > 0 else { return } currentBlankIndex -= 1
currentBlankIndex -= 1 filledBlanks[currentBlankIndex] = ""
filledBlanks[currentBlankIndex] = "" },
}, startActive: true
isActive: $isFocused )
) .frame(width: 0, height: 0)
.frame(width: 0, height: 0) .overlay(alignment: .top) {
HStack(spacing: 4) { HStack(spacing: 4) {
ForEach(Array(chars.enumerated()), id: \.offset) { index, ch in ForEach(0..<chars.count, id: \.self) { index in
if ch == "_" { charView(at: index)
blankSlot(index: index)
} else {
Text(ch.uppercased())
.font(.title2.weight(.bold))
.frame(width: 22)
}
} }
} }
} }
.onTapGesture { isFocused = true }
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
isFocused = true
}
}
} }
@ViewBuilder @ViewBuilder
private func blankSlot(index: Int) -> some View { private func charView(at index: Int) -> some View {
let blankIndex = blankPositions.firstIndex(of: index) ?? 0 let ch = chars[index]
let isCurrent = blankIndex == currentBlankIndex && isFocused if ch == "_" {
let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : nil let blankIndex = blankPositions.firstIndex(of: index) ?? index
let isCurrent = blankIndex == currentBlankIndex
ZStack { let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : nil
RoundedRectangle(cornerRadius: 6) ZStack {
.fill(.quaternary.opacity(0.2)) RoundedRectangle(cornerRadius: 6)
.frame(width: 28, height: 40) .fill(.quaternary.opacity(0.2))
RoundedRectangle(cornerRadius: 6) .frame(width: 28, height: 40)
.stroke(.quaternary, lineWidth: 1) RoundedRectangle(cornerRadius: 6)
.frame(width: 28, height: 40) .stroke(.quaternary, lineWidth: 1)
if isCurrent { .frame(width: 28, height: 40)
.opacity(isCurrent ? 0 : 1)
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
.stroke(.tint, lineWidth: 2) .stroke(.tint, lineWidth: 2)
.frame(width: 28, height: 40) .frame(width: 28, height: 40)
.opacity(isCurrent ? 1 : 0)
if let filled, !filled.isEmpty {
Text(filled.uppercased())
.font(.title2.weight(.bold))
} else if isCurrent {
BlinkingCursor()
}
} }
} else {
if let filled, !filled.isEmpty { Text(ch.uppercased())
Text(filled.uppercased()) .font(.title2.weight(.bold))
.font(.title2.weight(.bold)) .frame(width: 22)
} else if isCurrent {
BlinkingCursor()
}
} }
} }
} }