修复拼写页 AttributeGraph 无限递归闪退
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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,7 +79,6 @@ 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 }
|
||||||
@@ -93,36 +90,25 @@ struct SpellingWordInput: View {
|
|||||||
currentBlankIndex -= 1
|
currentBlankIndex -= 1
|
||||||
filledBlanks[currentBlankIndex] = ""
|
filledBlanks[currentBlankIndex] = ""
|
||||||
},
|
},
|
||||||
isActive: $isFocused
|
startActive: true
|
||||||
)
|
)
|
||||||
.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 blankIndex = blankPositions.firstIndex(of: index) ?? index
|
||||||
|
let isCurrent = blankIndex == currentBlankIndex
|
||||||
let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : nil
|
let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : nil
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(.quaternary.opacity(0.2))
|
.fill(.quaternary.opacity(0.2))
|
||||||
@@ -130,12 +116,11 @@ struct SpellingWordInput: View {
|
|||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.stroke(.quaternary, lineWidth: 1)
|
.stroke(.quaternary, lineWidth: 1)
|
||||||
.frame(width: 28, height: 40)
|
.frame(width: 28, height: 40)
|
||||||
if isCurrent {
|
.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 {
|
if let filled, !filled.isEmpty {
|
||||||
Text(filled.uppercased())
|
Text(filled.uppercased())
|
||||||
.font(.title2.weight(.bold))
|
.font(.title2.weight(.bold))
|
||||||
@@ -143,5 +128,10 @@ struct SpellingWordInput: View {
|
|||||||
BlinkingCursor()
|
BlinkingCursor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Text(ch.uppercased())
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.frame(width: 22)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user