应用改用 UIKit 重写,删除 SwiftUI 版本
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
import UIKit
|
||||
|
||||
final class QuizViewController: UIViewController {
|
||||
private let config: QuizConfig
|
||||
private let speech = SpeechService.shared
|
||||
|
||||
private var questions: [QuizQuestion]
|
||||
private var currentIndex = 0
|
||||
private var selectedOption: String?
|
||||
private var wrongWords: [Word] = []
|
||||
private var startDate = Date.now
|
||||
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
private let countLabel = UILabel()
|
||||
private let promptLabel = UILabel()
|
||||
private let phoneticLabel = UILabel()
|
||||
private let speakerButton = UIButton(type: .system)
|
||||
private let optionsStack = UIStackView()
|
||||
private let spellingView = SpellingInputView()
|
||||
private let spellingHintLabel = UILabel()
|
||||
private let resultLabel = UILabel()
|
||||
private let resultPhoneticLabel = UILabel()
|
||||
private let nextButton = UIButton(type: .system)
|
||||
private var optionButtons: [UIButton] = []
|
||||
|
||||
private var current: QuizQuestion? {
|
||||
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
|
||||
}
|
||||
|
||||
init(config: QuizConfig) {
|
||||
self.config = config
|
||||
self.questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = config.mode.rawValue
|
||||
buildLayout()
|
||||
showQuestion()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if config.mode == .spelling, selectedOption == nil {
|
||||
spellingView.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func buildLayout() {
|
||||
countLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
countLabel.textColor = .secondaryLabel
|
||||
countLabel.textAlignment = .center
|
||||
|
||||
promptLabel.font = .systemFont(ofSize: config.mode == .enToZh ? 44 : 34, weight: .bold)
|
||||
promptLabel.textAlignment = .center
|
||||
promptLabel.numberOfLines = 0
|
||||
|
||||
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
|
||||
phoneticLabel.textColor = .secondaryLabel
|
||||
phoneticLabel.textAlignment = .center
|
||||
|
||||
speakerButton.setImage(UIImage(systemName: "speaker.wave.2.fill"), for: .normal)
|
||||
speakerButton.addAction(UIAction { [weak self] _ in
|
||||
guard let self, let question = current else { return }
|
||||
speech.speak(question.prompt)
|
||||
}, for: .touchUpInside)
|
||||
|
||||
optionsStack.axis = .vertical
|
||||
optionsStack.spacing = 12
|
||||
|
||||
spellingHintLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
spellingHintLabel.textColor = .tertiaryLabel
|
||||
spellingHintLabel.textAlignment = .center
|
||||
|
||||
resultLabel.font = .preferredFont(forTextStyle: .title2).withWeight(.bold)
|
||||
resultLabel.textAlignment = .center
|
||||
|
||||
resultPhoneticLabel.font = .preferredFont(forTextStyle: .subheadline)
|
||||
resultPhoneticLabel.textColor = .secondaryLabel
|
||||
resultPhoneticLabel.textAlignment = .center
|
||||
|
||||
var nextConfig = UIButton.Configuration.borderedProminent()
|
||||
nextConfig.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||
nextButton.configuration = nextConfig
|
||||
nextButton.addAction(UIAction { [weak self] _ in self?.next() }, for: .touchUpInside)
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [progressView, countLabel])
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = 8
|
||||
|
||||
let promptStack = UIStackView(arrangedSubviews: [promptLabel, phoneticLabel, speakerButton])
|
||||
promptStack.axis = .vertical
|
||||
promptStack.spacing = 12
|
||||
promptStack.alignment = .center
|
||||
|
||||
let inputStack = UIStackView(arrangedSubviews: [optionsStack, spellingView, spellingHintLabel])
|
||||
inputStack.axis = .vertical
|
||||
inputStack.spacing = 16
|
||||
|
||||
let resultStack = UIStackView(arrangedSubviews: [resultLabel, resultPhoneticLabel, nextButton])
|
||||
resultStack.axis = .vertical
|
||||
resultStack.spacing = 12
|
||||
|
||||
let mainStack = UIStackView(arrangedSubviews: [headerStack, promptStack, inputStack, resultStack, UIView()])
|
||||
mainStack.axis = .vertical
|
||||
mainStack.spacing = 24
|
||||
view.addSubview(mainStack)
|
||||
mainStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
mainStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
||||
mainStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
mainStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
mainStack.bottomAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Question flow
|
||||
|
||||
private func showQuestion() {
|
||||
guard let question = current else { return }
|
||||
selectedOption = nil
|
||||
|
||||
progressView.progress = Float(currentIndex) / Float(questions.count)
|
||||
countLabel.text = "第 \(currentIndex + 1) / \(questions.count) 题"
|
||||
promptLabel.text = question.prompt
|
||||
phoneticLabel.isHidden = config.mode != .enToZh
|
||||
speakerButton.isHidden = config.mode != .enToZh
|
||||
phoneticLabel.text = question.word.phonetic
|
||||
|
||||
resultLabel.isHidden = true
|
||||
resultPhoneticLabel.isHidden = true
|
||||
nextButton.isHidden = true
|
||||
|
||||
let isSpelling = config.mode == .spelling
|
||||
optionsStack.isHidden = isSpelling
|
||||
spellingView.isHidden = !isSpelling
|
||||
spellingHintLabel.isHidden = !isSpelling
|
||||
|
||||
if isSpelling {
|
||||
spellingHintLabel.text = "\(question.word.word.count) 个字母,补全空缺部分"
|
||||
spellingView.configure(blankedWord: question.blankedWord, blankPositions: question.blankPositions)
|
||||
spellingView.onComplete = { [weak self] typed in
|
||||
self?.submitSpelling(typed, for: question)
|
||||
}
|
||||
spellingView.activate()
|
||||
} else {
|
||||
buildOptionButtons(for: question)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildOptionButtons(for question: QuizQuestion) {
|
||||
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
optionButtons = question.options.map { option in
|
||||
let button = UIButton(type: .system)
|
||||
var config = UIButton.Configuration.bordered()
|
||||
config.title = option
|
||||
config.titleTextAttributesTransformer = .init { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .preferredFont(forTextStyle: .headline)
|
||||
return outgoing
|
||||
}
|
||||
if self.config.mode == .zhToEn, let w = self.config.words.first(where: { $0.word == option }) {
|
||||
config.subtitle = w.phonetic
|
||||
}
|
||||
config.contentInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
|
||||
button.configuration = config
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.selectOption(option, for: question)
|
||||
}, for: .touchUpInside)
|
||||
optionsStack.addArrangedSubview(button)
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOption(_ option: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = option
|
||||
if option != question.answer {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
for (index, button) in optionButtons.enumerated() {
|
||||
let value = question.options[index]
|
||||
button.isEnabled = false
|
||||
if value == question.answer {
|
||||
button.configuration?.baseForegroundColor = .systemGreen
|
||||
} else if value == option {
|
||||
button.configuration?.baseForegroundColor = .systemRed
|
||||
} else {
|
||||
button.configuration?.baseForegroundColor = .systemGray
|
||||
}
|
||||
}
|
||||
showResult(option == question.answer, for: question)
|
||||
}
|
||||
|
||||
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = typed
|
||||
if typed != question.answer {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
spellingView.deactivate()
|
||||
showResult(typed == question.answer, for: question)
|
||||
}
|
||||
|
||||
private func showResult(_ correct: Bool, for question: QuizQuestion) {
|
||||
if config.mode == .spelling {
|
||||
resultLabel.text = correct ? "正确" : "正确答案:\(question.answer)"
|
||||
resultPhoneticLabel.text = question.word.phonetic
|
||||
resultPhoneticLabel.isHidden = false
|
||||
} else {
|
||||
resultLabel.text = correct ? "回答正确" : "正确答案:\(question.answer)"
|
||||
}
|
||||
resultLabel.textColor = correct ? .systemGreen : .systemRed
|
||||
resultLabel.isHidden = false
|
||||
|
||||
nextButton.configuration?.title = currentIndex + 1 < questions.count ? "下一题" : "查看成绩"
|
||||
nextButton.isHidden = false
|
||||
}
|
||||
|
||||
private func next() {
|
||||
if currentIndex + 1 < questions.count {
|
||||
currentIndex += 1
|
||||
showQuestion()
|
||||
} else {
|
||||
showQuizResult()
|
||||
}
|
||||
}
|
||||
|
||||
private func showQuizResult() {
|
||||
let resultView = QuizResultView(
|
||||
total: questions.count,
|
||||
wrongWords: wrongWords,
|
||||
duration: Date.now.timeIntervalSince(startDate),
|
||||
onRestart: { [weak self] in self?.restart() },
|
||||
onExit: { [weak self] in self?.navigationController?.popViewController(animated: true) },
|
||||
onSelectWord: { [weak self] word in
|
||||
self?.navigationController?.pushViewController(WordDetailViewController(word: word), animated: true)
|
||||
}
|
||||
)
|
||||
resultView.translatesAutoresizingMaskIntoConstraints = false
|
||||
resultView.frame = view.bounds
|
||||
resultView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(resultView)
|
||||
}
|
||||
|
||||
private func restart() {
|
||||
view.subviews.compactMap { $0 as? QuizResultView }.forEach { $0.removeFromSuperview() }
|
||||
questions = QuizGenerator.makeQuestions(mode: config.mode, words: config.words)
|
||||
currentIndex = 0
|
||||
wrongWords = []
|
||||
startDate = .now
|
||||
showQuestion()
|
||||
if config.mode == .spelling {
|
||||
spellingView.activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIFont {
|
||||
func withWeight(_ weight: UIFont.Weight) -> UIFont {
|
||||
let descriptor = fontDescriptor.addingAttributes([
|
||||
.traits: [UIFontDescriptor.TraitKey.weight: weight]
|
||||
])
|
||||
return UIFont(descriptor: descriptor, size: pointSize)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user