简化拼写交互:展示空缺词加输入框填完整单词

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 21:37:11 +08:00
parent 86a4a3b168
commit f5324bd57b
2 changed files with 24 additions and 169 deletions
+24 -32
View File
@@ -9,8 +9,7 @@ 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 filledBlanks: [String] = [] @State private var userInput = ""
@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
@@ -40,25 +39,6 @@ struct QuizView: View {
} }
.navigationTitle(config.mode.rawValue) .navigationTitle(config.mode.rawValue)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onChange(of: current?.id) { _, _ in
resetBlanks()
}
.onChange(of: currentBlankIndex) { _, _ in
guard config.mode == .spelling, let question = current else { return }
if currentBlankIndex == question.blankPositions.count {
let typed = filledBlanks.joined()
selectedOption = typed
if typed != question.answer {
wrongWords.append(question.word)
}
}
}
}
private func resetBlanks() {
guard config.mode == .spelling, let question = current else { return }
filledBlanks = Array(repeating: "", count: question.blankPositions.count)
currentBlankIndex = 0
} }
private func questionView(_ question: QuizQuestion) -> some View { private func questionView(_ question: QuizQuestion) -> some View {
@@ -151,18 +131,30 @@ struct QuizView: View {
} }
private func spellingView(_ question: QuizQuestion) -> some View { private func spellingView(_ question: QuizQuestion) -> some View {
VStack(spacing: 16) { VStack(spacing: 20) {
Text(question.blankedWord)
.font(.system(size: 44, weight: .bold, design: .monospaced))
.tracking(6)
Text("\(question.word.word.count) 个字母") Text("\(question.word.word.count) 个字母")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
SpellingWordInput( HStack(spacing: 12) {
blankedWord: question.blankedWord, TextField("填写完整单词", text: $userInput)
blankPositions: question.blankPositions, .textFieldStyle(.roundedBorder)
currentBlankIndex: $currentBlankIndex, .autocapitalization(.none)
filledBlanks: $filledBlanks .disableAutocorrection(true)
) .disabled(selectedOption != nil)
.disabled(selectedOption != nil) Button("确认") {
.opacity(selectedOption != nil ? 0.6 : 1) let answer = userInput.trimmingCharacters(in: .whitespaces).lowercased()
guard !answer.isEmpty else { return }
selectedOption = answer
if answer != question.answer {
wrongWords.append(question.word)
}
}
.buttonStyle(.borderedProminent)
.disabled(userInput.trimmingCharacters(in: .whitespaces).isEmpty || selectedOption != nil)
}
} }
} }
@@ -185,6 +177,7 @@ struct QuizView: View {
if currentIndex + 1 < questions.count { if currentIndex + 1 < questions.count {
currentIndex += 1 currentIndex += 1
selectedOption = nil selectedOption = nil
userInput = ""
} else { } else {
finished = true finished = true
} }
@@ -194,8 +187,7 @@ struct QuizView: View {
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
filledBlanks = [] userInput = ""
currentBlankIndex = 0
wrongWords = [] wrongWords = []
finished = false finished = false
startDate = .now startDate = .now
@@ -1,137 +0,0 @@
import SwiftUI
import UIKit
struct KeystrokeCaptureField: UIViewRepresentable {
let onChar: (Character) -> Void
let onBackspace: () -> Void
let startActive: Bool
func makeUIView(context: Context) -> UITextField {
let tf = UITextField()
tf.delegate = context.coordinator
tf.autocorrectionType = .no
tf.autocapitalizationType = .none
tf.spellCheckingType = .no
context.coordinator.textField = tf
return tf
}
func makeCoordinator() -> Coordinator {
Coordinator(onChar: onChar, onBackspace: onBackspace)
}
func updateUIView(_ uiView: UITextField, context: Context) {
if startActive && !context.coordinator.didFocus {
uiView.becomeFirstResponder()
context.coordinator.didFocus = true
}
}
class Coordinator: NSObject, UITextFieldDelegate {
let onChar: (Character) -> Void
let onBackspace: () -> Void
weak var textField: UITextField?
var didFocus = false
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
}
}
}
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 currentBlankIndex: Int
@Binding var filledBlanks: [String]
private let chars: [String]
init(blankedWord: String, blankPositions: [Int], currentBlankIndex: Binding<Int>, filledBlanks: Binding<[String]>) {
self.blankedWord = blankedWord
self.blankPositions = blankPositions
self._currentBlankIndex = currentBlankIndex
self._filledBlanks = filledBlanks
self.chars = blankedWord.map { String($0) }
}
var body: some View {
KeystrokeCaptureField(
onChar: { char in
guard currentBlankIndex < blankPositions.count else { return }
filledBlanks[currentBlankIndex] = String(char).lowercased()
currentBlankIndex += 1
},
onBackspace: {
guard currentBlankIndex > 0 else { return }
currentBlankIndex -= 1
filledBlanks[currentBlankIndex] = ""
},
startActive: true
)
.frame(width: 0, height: 0)
.overlay(alignment: .top) {
HStack(spacing: 4) {
ForEach(0..<chars.count, id: \.self) { index in
charView(at: index)
}
}
}
}
@ViewBuilder
private func charView(at index: Int) -> some View {
let ch = chars[index]
if ch == "_" {
let blankIndex = blankPositions.firstIndex(of: index) ?? index
let isCurrent = blankIndex == currentBlankIndex
let filled = blankIndex < filledBlanks.count ? filledBlanks[blankIndex] : nil
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary.opacity(0.2))
.frame(width: 28, height: 40)
RoundedRectangle(cornerRadius: 6)
.stroke(.quaternary, lineWidth: 1)
.frame(width: 28, height: 40)
.opacity(isCurrent ? 0 : 1)
RoundedRectangle(cornerRadius: 6)
.stroke(.tint, lineWidth: 2)
.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 {
Text(ch.uppercased())
.font(.title2.weight(.bold))
.frame(width: 22)
}
}
}