From 4e3146f9feae5df52eb82959b5b162a986d45986 Mon Sep 17 00:00:00 2001 From: fish Date: Sat, 18 Jul 2026 21:56:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=BC=E5=86=99=E6=94=B9=E4=B8=BA=E5=9C=A8?= =?UTF-8?q?=E7=A9=BA=E7=BC=BA=E5=AD=97=E6=AF=8D=E6=A0=BC=E5=86=85=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=A1=AB=E5=AD=97=E6=AF=8D=EF=BC=8C=E5=A1=AB=E5=AE=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../LearnEnglish/Views/QuizView.swift | 96 ++++------- .../Views/SpellingWordInput.swift | 154 ++++++++++++++++++ 2 files changed, 189 insertions(+), 61 deletions(-) create mode 100644 LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift diff --git a/LearnEnglish/LearnEnglish/Views/QuizView.swift b/LearnEnglish/LearnEnglish/Views/QuizView.swift index b8f1469..9562cb8 100644 --- a/LearnEnglish/LearnEnglish/Views/QuizView.swift +++ b/LearnEnglish/LearnEnglish/Views/QuizView.swift @@ -9,14 +9,17 @@ struct QuizView: View { @State private var questions: [QuizQuestion] @State private var currentIndex = 0 @State private var selectedOption: String? - @State private var userInput = "" + @State private var filledBlanks: [String] = [] + @State private var currentBlankIndex = 0 @State private var wrongWords: [Word] = [] @State private var finished = false @State private var startDate = Date.now init(config: QuizConfig) { self.config = config - _questions = State(initialValue: QuizGenerator.makeQuestions(mode: config.mode, words: config.words)) + let questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words) + _questions = State(initialValue: questions) + _filledBlanks = State(initialValue: Array(repeating: "", count: questions.first?.blankPositions.count ?? 0)) } private var current: QuizQuestion? { @@ -131,65 +134,31 @@ struct QuizView: View { } private func spellingView(_ question: QuizQuestion) -> some View { - VStack(spacing: 24) { - Text(question.prompt) - .font(.title.weight(.medium)) - .foregroundStyle(.secondary) + VStack(spacing: 16) { + SpellingWordInput( + blankedWord: question.blankedWord, + blankPositions: question.blankPositions, + filledBlanks: $filledBlanks, + currentBlankIndex: $currentBlankIndex, + isActive: selectedOption == nil, + onComplete: { submitSpelling(question) } + ) - VStack(spacing: 8) { - HStack(spacing: 6) { - ForEach(Array(question.blankedWord.enumerated()), id: \.offset) { i, ch in - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(.quaternary.opacity(0.15)) - .frame(width: 36, height: 44) - RoundedRectangle(cornerRadius: 8) - .stroke(.quaternary, lineWidth: 1) - .frame(width: 36, height: 44) - if ch == "_" { - Rectangle() - .fill(.secondary.opacity(0.5)) - .frame(width: 16, height: 2) - .offset(y: 12) - } else { - Text(ch.uppercased()) - .font(.title2.weight(.bold)) - } - } - } - } + Text("\(question.word.word.count) 个字母,补全空缺部分") + .font(.caption) + .foregroundStyle(.tertiary) + } + } - Text("\(question.word.word.count) 个字母,填写完整单词") - .font(.caption) - .foregroundStyle(.tertiary) - } - - TextField("在此输入完整单词", text: $userInput) - .font(.body) - .autocapitalization(.none) - .disableAutocorrection(true) - .disabled(selectedOption != nil) - .padding(.horizontal, 16) - .padding(.vertical, 14) - .background(.quaternary.opacity(0.2), in: RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(.quaternary, lineWidth: 1) - ) - - Button("确认") { - let answer = userInput.trimmingCharacters(in: .whitespaces).lowercased() - guard !answer.isEmpty else { return } - selectedOption = answer - if answer != question.answer { - wrongWords.append(question.word) - } - } - .buttonStyle(.borderedProminent) - .font(.headline) - .frame(maxWidth: .infinity) - .frame(height: 44) - .disabled(userInput.trimmingCharacters(in: .whitespaces).isEmpty || selectedOption != nil) + private func submitSpelling(_ question: QuizQuestion) { + var chars = question.blankedWord.map { String($0) } + for (i, pos) in question.blankPositions.enumerated() where i < filledBlanks.count { + chars[pos] = filledBlanks[i] + } + let typed = chars.joined().lowercased() + selectedOption = typed + if typed != question.answer { + wrongWords.append(question.word) } } @@ -212,17 +181,22 @@ struct QuizView: View { if currentIndex + 1 < questions.count { currentIndex += 1 selectedOption = nil - userInput = "" + resetBlanks() } else { finished = true } } + private func resetBlanks() { + filledBlanks = Array(repeating: "", count: current?.blankPositions.count ?? 0) + currentBlankIndex = 0 + } + private func restart() { questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words) currentIndex = 0 selectedOption = nil - userInput = "" + resetBlanks() wrongWords = [] finished = false startDate = .now diff --git a/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift b/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift new file mode 100644 index 0000000..496fb79 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/SpellingWordInput.swift @@ -0,0 +1,154 @@ +import SwiftUI +import UIKit + +struct KeystrokeCaptureField: UIViewRepresentable { + let isActive: Bool + var onChar: (Character) -> Void + var onBackspace: () -> Void + + func makeUIView(context: Context) -> UITextField { + let tf = UITextField() + tf.delegate = context.coordinator + tf.autocorrectionType = .no + tf.autocapitalizationType = .none + tf.spellCheckingType = .no + tf.keyboardType = .asciiCapable + return tf + } + + func updateUIView(_ uiView: UITextField, context: Context) { + context.coordinator.onChar = onChar + context.coordinator.onBackspace = onBackspace + // becomeFirstResponder must not run synchronously inside a SwiftUI update + // cycle — it triggers keyboard/layout feedback that recurses AttributeGraph. + if isActive, !uiView.isFirstResponder { + DispatchQueue.main.async { [weak uiView] in + guard let uiView, !uiView.isFirstResponder, uiView.window != nil else { return } + uiView.becomeFirstResponder() + } + } else if !isActive, uiView.isFirstResponder { + DispatchQueue.main.async { [weak uiView] in + uiView?.resignFirstResponder() + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(onChar: onChar, onBackspace: onBackspace) + } + + class Coordinator: NSObject, UITextFieldDelegate { + var onChar: (Character) -> Void + var onBackspace: () -> Void + + init(onChar: @escaping (Character) -> Void, onBackspace: @escaping () -> Void) { + self.onChar = onChar + self.onBackspace = onBackspace + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if string.isEmpty { + onBackspace() + } else if let char = string.last { + onChar(char) + } + textField.text = nil + return false + } + } +} + +private struct BlinkingCursor: View { + var body: some View { + TimelineView(.periodic(from: .now, by: 0.6)) { timeline in + let visible = Int(timeline.date.timeIntervalSinceReferenceDate / 0.6) % 2 == 0 + Rectangle() + .fill(.tint) + .frame(width: 2, height: 22) + .opacity(visible ? 1 : 0) + } + } +} + +struct SpellingWordInput: View { + let blankedWord: String + let blankPositions: [Int] + @Binding var filledBlanks: [String] + @Binding var currentBlankIndex: Int + let isActive: Bool + let onComplete: () -> Void + + private var chars: [String] { blankedWord.map { String($0) } } + + var body: some View { + KeystrokeCaptureField( + isActive: isActive, + onChar: { char in + guard char.isLetter, currentBlankIndex < blankPositions.count else { return } + filledBlanks[currentBlankIndex] = String(char).lowercased() + currentBlankIndex += 1 + if currentBlankIndex == blankPositions.count { + onComplete() + } + }, + onBackspace: { + guard currentBlankIndex > 0 else { return } + currentBlankIndex -= 1 + filledBlanks[currentBlankIndex] = "" + } + ) + .frame(width: 0, height: 0) + .overlay(alignment: .top) { + HStack(spacing: 6) { + ForEach(Array(chars.enumerated()), id: \.offset) { index, ch in + charView(at: index, ch: ch) + } + } + } + .allowsHitTesting(false) + } + + @ViewBuilder + private func charView(at index: Int, ch: String) -> some View { + if ch == "_" { + blankSlot(at: index) + } else { + slotBox(isCurrent: false) { + Text(ch.uppercased()) + .font(.title2.weight(.bold)) + } + } + } + + private func blankSlot(at index: Int) -> some View { + let blankIndex = blankPositions.firstIndex(of: index) ?? 0 + let isCurrent = isActive && blankIndex == currentBlankIndex + let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : "" + + return slotBox(isCurrent: isCurrent) { + if !filled.isEmpty { + Text(filled.uppercased()) + .font(.title2.weight(.bold)) + } else if isCurrent { + BlinkingCursor() + } else { + Rectangle() + .fill(.secondary.opacity(0.5)) + .frame(width: 16, height: 2) + .offset(y: 12) + } + } + } + + private func slotBox(isCurrent: Bool, @ViewBuilder content: () -> Content) -> some View { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary.opacity(0.15)) + .frame(width: 36, height: 44) + RoundedRectangle(cornerRadius: 8) + .stroke(isCurrent ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary), lineWidth: isCurrent ? 2 : 1) + .frame(width: 36, height: 44) + content() + } + } +}