引入设计系统,统一 UI 风格,增加拼写/默写结果状态反馈
This commit is contained in:
@@ -23,6 +23,11 @@ final class QuizViewController: UIViewController {
|
||||
private let resultPhoneticLabel = UILabel()
|
||||
private let nextButton = UIButton(type: .system)
|
||||
private var optionButtons: [UIButton] = []
|
||||
private var optionIcons: [UIImageView] = []
|
||||
|
||||
private let promptStack = UIStackView()
|
||||
private let inputStack = UIStackView()
|
||||
private let resultStack = UIStackView()
|
||||
|
||||
private var current: QuizQuestion? {
|
||||
questions.indices.contains(currentIndex) ? questions[currentIndex] : nil
|
||||
@@ -41,6 +46,7 @@ final class QuizViewController: UIViewController {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = config.mode.rawValue
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
buildLayout()
|
||||
showQuestion()
|
||||
}
|
||||
@@ -57,11 +63,17 @@ final class QuizViewController: UIViewController {
|
||||
// MARK: - Layout
|
||||
|
||||
private func buildLayout() {
|
||||
progressView.transform = CGAffineTransform(scaleX: 1, y: 0.75)
|
||||
progressView.trackTintColor = .tertiarySystemFill
|
||||
progressView.progressTintColor = Theme.Color.accent
|
||||
|
||||
countLabel.font = .preferredFont(forTextStyle: .caption1)
|
||||
countLabel.textColor = .secondaryLabel
|
||||
countLabel.textAlignment = .center
|
||||
|
||||
promptLabel.font = .systemFont(ofSize: config.mode == .enToZh ? 44 : 34, weight: .bold)
|
||||
promptLabel.font = config.mode == .enToZh
|
||||
? Theme.Font.wordHero(size: 40)
|
||||
: .systemFont(ofSize: 34, weight: .bold)
|
||||
promptLabel.textAlignment = .center
|
||||
promptLabel.numberOfLines = 0
|
||||
|
||||
@@ -69,7 +81,10 @@ final class QuizViewController: UIViewController {
|
||||
phoneticLabel.textColor = .secondaryLabel
|
||||
phoneticLabel.textAlignment = .center
|
||||
|
||||
speakerButton.setImage(UIImage(systemName: "speaker.wave.2.fill"), for: .normal)
|
||||
speakerButton.setImage(UIImage(systemName: "speaker.wave.2.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)),
|
||||
for: .normal)
|
||||
speakerButton.tintColor = Theme.Color.accent
|
||||
speakerButton.addAction(UIAction { [weak self] _ in
|
||||
guard let self, let question = current else { return }
|
||||
speech.speak(question.prompt)
|
||||
@@ -91,6 +106,7 @@ final class QuizViewController: UIViewController {
|
||||
|
||||
var nextConfig = UIButton.Configuration.borderedProminent()
|
||||
nextConfig.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
|
||||
nextConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||
nextButton.configuration = nextConfig
|
||||
nextButton.addAction(UIAction { [weak self] _ in self?.primaryButtonTapped() }, for: .touchUpInside)
|
||||
|
||||
@@ -105,18 +121,18 @@ final class QuizViewController: UIViewController {
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = 8
|
||||
|
||||
let promptStack = UIStackView(arrangedSubviews: [promptLabel, phoneticLabel, speakerButton])
|
||||
promptStack.axis = .vertical
|
||||
promptStack.spacing = 12
|
||||
promptStack.alignment = .center
|
||||
[promptLabel, phoneticLabel, speakerButton].forEach { promptStack.addArrangedSubview($0) }
|
||||
|
||||
let inputStack = UIStackView(arrangedSubviews: [optionsStack, spellingView, dictationView, spellingHintLabel])
|
||||
inputStack.axis = .vertical
|
||||
inputStack.spacing = 16
|
||||
[optionsStack, spellingView, dictationView, spellingHintLabel].forEach { inputStack.addArrangedSubview($0) }
|
||||
|
||||
let resultStack = UIStackView(arrangedSubviews: [resultLabel, resultPhoneticLabel, nextButton])
|
||||
resultStack.axis = .vertical
|
||||
resultStack.spacing = 12
|
||||
[resultLabel, resultPhoneticLabel, nextButton].forEach { resultStack.addArrangedSubview($0) }
|
||||
|
||||
let mainStack = UIStackView(arrangedSubviews: [headerStack, promptStack, inputStack, resultStack, UIView()])
|
||||
mainStack.axis = .vertical
|
||||
@@ -137,8 +153,8 @@ final class QuizViewController: UIViewController {
|
||||
guard let question = current else { return }
|
||||
selectedOption = nil
|
||||
|
||||
progressView.progress = Float(currentIndex) / Float(questions.count)
|
||||
countLabel.text = "第 \(currentIndex + 1) / \(questions.count) 题"
|
||||
progressView.setProgress(Float(currentIndex) / Float(questions.count), animated: true)
|
||||
countLabel.text = "\(currentIndex + 1) / \(questions.count)"
|
||||
promptLabel.text = question.prompt
|
||||
phoneticLabel.isHidden = config.mode != .enToZh
|
||||
speakerButton.isHidden = config.mode != .enToZh
|
||||
@@ -188,66 +204,119 @@ final class QuizViewController: UIViewController {
|
||||
|
||||
private func buildOptionButtons(for question: QuizQuestion) {
|
||||
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
optionButtons = question.options.map { option in
|
||||
optionButtons = []
|
||||
optionIcons = []
|
||||
for option in question.options {
|
||||
let button = UIButton(type: .system)
|
||||
var config = UIButton.Configuration.bordered()
|
||||
config.title = option
|
||||
config.titleTextAttributesTransformer = .init { incoming in
|
||||
var buttonConfig = UIButton.Configuration.bordered()
|
||||
buttonConfig.title = option
|
||||
buttonConfig.titleAlignment = .leading
|
||||
buttonConfig.baseForegroundColor = .label
|
||||
buttonConfig.background.backgroundColor = Theme.Color.cardBackground
|
||||
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
|
||||
buttonConfig.background.strokeColor = .separator
|
||||
buttonConfig.background.strokeWidth = 1
|
||||
if self.config.mode == .zhToEn, let w = self.config.words.first(where: { $0.word == option }) {
|
||||
buttonConfig.subtitle = w.phonetic
|
||||
}
|
||||
buttonConfig.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
|
||||
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 44)
|
||||
button.configuration = buttonConfig
|
||||
button.contentHorizontalAlignment = .leading
|
||||
|
||||
let icon = UIImageView()
|
||||
icon.isHidden = true
|
||||
icon.contentMode = .scaleAspectFit
|
||||
button.addSubview(icon)
|
||||
icon.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
icon.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -14),
|
||||
icon.centerYAnchor.constraint(equalTo: button.centerYAnchor)
|
||||
])
|
||||
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.selectOption(option, for: question)
|
||||
}, for: .touchUpInside)
|
||||
optionsStack.addArrangedSubview(button)
|
||||
return button
|
||||
optionButtons.append(button)
|
||||
optionIcons.append(icon)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOption(_ option: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = option
|
||||
if option != question.answer {
|
||||
let correct = option == question.answer
|
||||
if !correct {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
correct ? Haptics.correct() : Haptics.wrong()
|
||||
|
||||
for (index, button) in optionButtons.enumerated() {
|
||||
let value = question.options[index]
|
||||
button.isEnabled = false
|
||||
button.isUserInteractionEnabled = false
|
||||
var buttonConfig = button.configuration
|
||||
if value == question.answer {
|
||||
button.configuration?.baseForegroundColor = .systemGreen
|
||||
buttonConfig?.background.strokeColor = Theme.Color.correct
|
||||
buttonConfig?.background.strokeWidth = 2
|
||||
buttonConfig?.background.backgroundColor = Theme.Color.correct.withAlphaComponent(0.12)
|
||||
buttonConfig?.baseForegroundColor = Theme.Color.correct
|
||||
optionIcons[index].image = UIImage(systemName: "checkmark.circle.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
||||
optionIcons[index].tintColor = Theme.Color.correct
|
||||
optionIcons[index].isHidden = false
|
||||
} else if value == option {
|
||||
button.configuration?.baseForegroundColor = .systemRed
|
||||
buttonConfig?.background.strokeColor = Theme.Color.wrong
|
||||
buttonConfig?.background.strokeWidth = 2
|
||||
buttonConfig?.background.backgroundColor = Theme.Color.wrong.withAlphaComponent(0.12)
|
||||
buttonConfig?.baseForegroundColor = Theme.Color.wrong
|
||||
optionIcons[index].image = UIImage(systemName: "xmark.circle.fill",
|
||||
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
|
||||
optionIcons[index].tintColor = Theme.Color.wrong
|
||||
optionIcons[index].isHidden = false
|
||||
} else {
|
||||
button.configuration?.baseForegroundColor = .systemGray
|
||||
button.alpha = 0.4
|
||||
}
|
||||
button.configuration = buttonConfig
|
||||
}
|
||||
for icon in optionIcons where !icon.isHidden {
|
||||
icon.transform = CGAffineTransform(scaleX: 0.4, y: 0.4)
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.5,
|
||||
initialSpringVelocity: 0, options: .allowUserInteraction) {
|
||||
icon.transform = .identity
|
||||
}
|
||||
}
|
||||
showResult(option == question.answer, for: question)
|
||||
showResult(correct, for: question)
|
||||
}
|
||||
|
||||
private func submitSpelling(_ typed: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = typed
|
||||
if typed != question.answer {
|
||||
let correct = typed == question.answer
|
||||
if !correct {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
correct ? Haptics.correct() : Haptics.wrong()
|
||||
spellingView.deactivate()
|
||||
showResult(typed == question.answer, for: question)
|
||||
spellingView.showResultState(correct: correct)
|
||||
showResult(correct, for: question)
|
||||
}
|
||||
|
||||
private func submitDictation(_ typed: String, for question: QuizQuestion) {
|
||||
guard selectedOption == nil else { return }
|
||||
selectedOption = typed
|
||||
if typed != question.answer {
|
||||
let correct = typed == question.answer
|
||||
if !correct {
|
||||
wrongWords.append(question.word)
|
||||
}
|
||||
correct ? Haptics.correct() : Haptics.wrong()
|
||||
dictationView.deactivate()
|
||||
showResult(typed == question.answer, for: question)
|
||||
dictationView.showResultState(correct: correct)
|
||||
showResult(correct, for: question)
|
||||
}
|
||||
|
||||
private func showResult(_ correct: Bool, for question: QuizQuestion) {
|
||||
@@ -258,7 +327,7 @@ final class QuizViewController: UIViewController {
|
||||
} else {
|
||||
resultLabel.text = correct ? "回答正确" : "正确答案:\(question.answer)"
|
||||
}
|
||||
resultLabel.textColor = correct ? .systemGreen : .systemRed
|
||||
resultLabel.textColor = correct ? Theme.Color.correct : Theme.Color.wrong
|
||||
resultLabel.isHidden = false
|
||||
|
||||
nextButton.configuration?.title = currentIndex + 1 < questions.count ? "下一题" : "查看成绩"
|
||||
@@ -268,7 +337,7 @@ final class QuizViewController: UIViewController {
|
||||
|
||||
@objc private func speakCurrentWord() {
|
||||
guard let question = current else { return }
|
||||
speech.speak(question.answer)
|
||||
speech.speak(question.word.word)
|
||||
}
|
||||
|
||||
private func primaryButtonTapped() {
|
||||
@@ -284,15 +353,34 @@ final class QuizViewController: UIViewController {
|
||||
}
|
||||
|
||||
private func next() {
|
||||
nextButton.isEnabled = false
|
||||
if currentIndex + 1 < questions.count {
|
||||
currentIndex += 1
|
||||
showQuestion()
|
||||
animateQuestionSwap()
|
||||
} else {
|
||||
showQuizResult()
|
||||
}
|
||||
}
|
||||
|
||||
private func animateQuestionSwap() {
|
||||
let stacks = [promptStack, inputStack, resultStack]
|
||||
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseIn) {
|
||||
stacks.forEach { $0.alpha = 0 }
|
||||
} completion: { _ in
|
||||
self.showQuestion()
|
||||
stacks.forEach { $0.transform = CGAffineTransform(translationX: 0, y: 12) }
|
||||
UIView.animate(withDuration: 0.3, delay: 0,
|
||||
options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState]) {
|
||||
stacks.forEach {
|
||||
$0.alpha = 1
|
||||
$0.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showQuizResult() {
|
||||
progressView.setProgress(1, animated: true)
|
||||
let resultView = QuizResultView(
|
||||
total: questions.count,
|
||||
wrongWords: wrongWords,
|
||||
@@ -303,10 +391,16 @@ final class QuizViewController: UIViewController {
|
||||
self?.navigationController?.pushViewController(WordDetailViewController(word: word), animated: true)
|
||||
}
|
||||
)
|
||||
resultView.translatesAutoresizingMaskIntoConstraints = false
|
||||
resultView.frame = view.bounds
|
||||
resultView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
resultView.alpha = 0
|
||||
resultView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||
view.addSubview(resultView)
|
||||
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.8,
|
||||
initialSpringVelocity: 0, options: .curveEaseOut) {
|
||||
resultView.alpha = 1
|
||||
resultView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
private func restart() {
|
||||
|
||||
Reference in New Issue
Block a user