拼写改为在空缺字母格内直接填字母,填完自动提交
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user