简化拼写交互:展示空缺词加输入框填完整单词
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
.opacity(selectedOption != nil ? 0.6 : 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)
|
||||||
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user