拼写改为在空缺字母格内直接填字母,填完自动提交

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 21:56:58 +08:00
parent b4b71a5075
commit 4e3146f9fe
2 changed files with 189 additions and 61 deletions
+35 -61
View File
@@ -9,14 +9,17 @@ struct QuizView: View {
@State private var questions: [QuizQuestion] @State private var questions: [QuizQuestion]
@State private var currentIndex = 0 @State private var currentIndex = 0
@State private var selectedOption: String? @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 wrongWords: [Word] = []
@State private var finished = false @State private var finished = false
@State private var startDate = Date.now @State private var startDate = Date.now
init(config: QuizConfig) { init(config: QuizConfig) {
self.config = config 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? { private var current: QuizQuestion? {
@@ -131,65 +134,31 @@ struct QuizView: View {
} }
private func spellingView(_ question: QuizQuestion) -> some View { private func spellingView(_ question: QuizQuestion) -> some View {
VStack(spacing: 24) { VStack(spacing: 16) {
Text(question.prompt) SpellingWordInput(
.font(.title.weight(.medium)) blankedWord: question.blankedWord,
.foregroundStyle(.secondary) blankPositions: question.blankPositions,
filledBlanks: $filledBlanks,
currentBlankIndex: $currentBlankIndex,
isActive: selectedOption == nil,
onComplete: { submitSpelling(question) }
)
VStack(spacing: 8) { Text("\(question.word.word.count) 个字母,补全空缺部分")
HStack(spacing: 6) { .font(.caption)
ForEach(Array(question.blankedWord.enumerated()), id: \.offset) { i, ch in .foregroundStyle(.tertiary)
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) 个字母,填写完整单词") private func submitSpelling(_ question: QuizQuestion) {
.font(.caption) var chars = question.blankedWord.map { String($0) }
.foregroundStyle(.tertiary) for (i, pos) in question.blankPositions.enumerated() where i < filledBlanks.count {
} chars[pos] = filledBlanks[i]
}
TextField("在此输入完整单词", text: $userInput) let typed = chars.joined().lowercased()
.font(.body) selectedOption = typed
.autocapitalization(.none) if typed != question.answer {
.disableAutocorrection(true) wrongWords.append(question.word)
.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)
} }
} }
@@ -212,17 +181,22 @@ struct QuizView: View {
if currentIndex + 1 < questions.count { if currentIndex + 1 < questions.count {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
userInput = "" resetBlanks()
} else { } else {
finished = true finished = true
} }
} }
private func resetBlanks() {
filledBlanks = Array(repeating: "", count: current?.blankPositions.count ?? 0)
currentBlankIndex = 0
}
private func restart() { private func restart() {
questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words) questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
currentIndex = 0 currentIndex = 0
selectedOption = nil selectedOption = nil
userInput = "" resetBlanks()
wrongWords = [] wrongWords = []
finished = false finished = false
startDate = .now startDate = .now
@@ -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<Content: View>(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()
}
}
}