引入设计系统,统一 UI 风格,增加拼写/默写结果状态反馈

This commit is contained in:
2026-07-18 23:55:00 +08:00
parent 1c3d62d46c
commit c3a4990984
15 changed files with 981 additions and 205 deletions
@@ -8,7 +8,6 @@ enum QuizMode: String, Hashable {
}
struct QuizSetupRequest: Hashable {
let mode: QuizMode
let words: [Word]
}
@@ -12,10 +12,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.tintColor = Theme.Color.accent
let store = WordStore()
store.load()
let root = CategoryListViewController(store: store)
window.rootViewController = UINavigationController(rootViewController: root)
let navigation = UINavigationController(rootViewController: root)
navigation.navigationBar.prefersLargeTitles = true
window.rootViewController = navigation
window.makeKeyAndVisible()
self.window = window
}
@@ -2,16 +2,20 @@ import AVFoundation
final class SpeechService: NSObject {
static let shared = SpeechService()
static let speakingDidChangeNotification = Notification.Name("SpeechServiceSpeakingDidChange")
private let synthesizer = AVSpeechSynthesizer()
private(set) var speakingText: String?
var onSpeakingChanged: (() -> Void)?
override init() {
super.init()
synthesizer.delegate = self
}
func isSpeaking(_ text: String) -> Bool {
speakingText == text
}
func speak(_ text: String) {
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
@@ -20,19 +24,23 @@ final class SpeechService: NSObject {
utterance.voice = AVSpeechSynthesisVoice(language: "en-US")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.9
speakingText = text
onSpeakingChanged?()
notifySpeakingChanged()
synthesizer.speak(utterance)
}
private func notifySpeakingChanged() {
NotificationCenter.default.post(name: Self.speakingDidChangeNotification, object: self)
}
}
extension SpeechService: AVSpeechSynthesizerDelegate {
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
speakingText = nil
onSpeakingChanged?()
notifySpeakingChanged()
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
speakingText = nil
onSpeakingChanged?()
notifySpeakingChanged()
}
}
@@ -0,0 +1,15 @@
import UIKit
enum Haptics {
static func selection() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
static func correct() {
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
static func wrong() {
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
@@ -0,0 +1,60 @@
import UIKit
enum Theme {
enum Color {
static let accent = UIColor.systemIndigo
static let cardBackground = UIColor.secondarySystemBackground
static let correct = UIColor.systemGreen
static let wrong = UIColor.systemRed
private static let categoryPalette: [UIColor] = [
.systemIndigo, .systemTeal, .systemOrange, .systemPurple, .systemGreen
]
static func category(at index: Int) -> UIColor {
categoryPalette[index % categoryPalette.count]
}
}
enum Metrics {
static let cardRadius: CGFloat = 16
static let controlRadius: CGFloat = 12
}
enum Font {
static func wordHero(size: CGFloat = 44) -> UIFont {
rounded(size: size, weight: .bold)
}
static func rounded(size: CGFloat, weight: UIFont.Weight) -> UIFont {
let base = UIFont.systemFont(ofSize: size, weight: weight)
guard let descriptor = base.fontDescriptor.withDesign(.rounded) else { return base }
return UIFont(descriptor: descriptor, size: size)
}
}
}
extension UIView {
func setPressed(_ pressed: Bool, animated: Bool = true) {
let update = {
self.transform = pressed ? CGAffineTransform(scaleX: 0.97, y: 0.97) : .identity
}
guard animated else {
update()
return
}
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 0.6,
initialSpringVelocity: 0, options: [.allowUserInteraction, .beginFromCurrentState],
animations: update)
}
func animateEntry(distance: CGFloat = 12, duration: TimeInterval = 0.3, delay: TimeInterval = 0) {
alpha = 0
transform = CGAffineTransform(translationX: 0, y: distance)
UIView.animate(withDuration: duration, delay: delay,
options: [.curveEaseOut, .allowUserInteraction, .beginFromCurrentState]) {
self.alpha = 1
self.transform = .identity
}
}
}
@@ -0,0 +1,83 @@
import UIKit
final class CategoryCardCell: UITableViewCell {
private let cardView = UIView()
private let iconTile = UIView()
private let iconView = UIImageView()
private let nameLabel = UILabel()
private let countLabel = UILabel()
private let chevronView = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
contentView.backgroundColor = .clear
selectionStyle = .none
cardView.backgroundColor = Theme.Color.cardBackground
cardView.layer.cornerRadius = Theme.Metrics.cardRadius
contentView.addSubview(cardView)
cardView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6),
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
])
iconTile.layer.cornerRadius = 12
iconView.contentMode = .scaleAspectFit
iconTile.addSubview(iconView)
iconTile.translatesAutoresizingMaskIntoConstraints = false
iconView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
iconTile.widthAnchor.constraint(equalToConstant: 44),
iconTile.heightAnchor.constraint(equalToConstant: 44),
iconView.centerXAnchor.constraint(equalTo: iconTile.centerXAnchor),
iconView.centerYAnchor.constraint(equalTo: iconTile.centerYAnchor)
])
nameLabel.font = .preferredFont(forTextStyle: .headline)
countLabel.font = .preferredFont(forTextStyle: .subheadline)
countLabel.textColor = .secondaryLabel
let textStack = UIStackView(arrangedSubviews: [nameLabel, countLabel])
textStack.axis = .vertical
textStack.spacing = 2
chevronView.image = UIImage(systemName: "chevron.right")
chevronView.tintColor = .tertiaryLabel
chevronView.contentMode = .scaleAspectFit
chevronView.setContentHuggingPriority(.required, for: .horizontal)
let row = UIStackView(arrangedSubviews: [iconTile, textStack, chevronView])
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
cardView.addSubview(row)
row.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 12),
row.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14),
row.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -14),
row.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -12)
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(with category: WordCategory, color: UIColor) {
nameLabel.text = category.name
countLabel.text = "\(category.words.count) 个单词"
iconView.image = UIImage(systemName: category.icon,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))
iconView.tintColor = color
iconTile.backgroundColor = color.withAlphaComponent(0.12)
}
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated)
cardView.setPressed(highlighted, animated: animated)
}
}
@@ -13,8 +13,11 @@ final class CategoryListViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = "单词分类"
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
title = "单词"
tableView.register(CategoryCardCell.self, forCellReuseIdentifier: "cell")
tableView.separatorStyle = .none
tableView.backgroundColor = .systemBackground
tableView.contentInset.top = 8
if let error = store.loadError {
showError(error)
}
@@ -35,7 +38,6 @@ final class CategoryListViewController: UITableViewController {
label.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 24)
])
tableView.backgroundView = background
tableView.separatorStyle = .none
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
@@ -43,24 +45,15 @@ final class CategoryListViewController: UITableViewController {
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let category = store.categories[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = category.name
content.textProperties.font = .preferredFont(forTextStyle: .headline)
content.secondaryText = "\(category.words.count) 个单词"
content.secondaryTextProperties.color = .secondaryLabel
content.image = UIImage(systemName: category.icon)
content.imageProperties.tintColor = .tintColor
content.imageProperties.preferredSymbolConfiguration = .init(font: .preferredFont(forTextStyle: .title2))
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CategoryCardCell
cell.configure(with: store.categories[indexPath.row], color: Theme.Color.category(at: indexPath.row))
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
Haptics.selection()
let category = store.categories[indexPath.row]
navigationController?.pushViewController(WordListViewController(category: category), animated: true)
navigationController?.pushViewController(
WordListViewController(category: category, colorIndex: indexPath.row), animated: true)
}
}
@@ -58,5 +58,13 @@ final class DictationInputView: UIView, UITextFieldDelegate {
func activate() { textField.becomeFirstResponder() }
func deactivate() { textField.resignFirstResponder() }
func reset() { textField.text = "" }
func reset() {
textField.text = ""
bottomLine.backgroundColor = .separator
}
func showResultState(correct: Bool) {
bottomLine.backgroundColor = correct ? Theme.Color.correct : Theme.Color.wrong
}
}
@@ -8,7 +8,16 @@ final class QuizResultView: UIView {
private let onExit: () -> Void
private let onSelectWord: (Word) -> Void
private let ringView = UIView()
private let ringTrackLayer = CAShapeLayer()
private let ringProgressLayer = CAShapeLayer()
private let scoreLabel = UILabel()
private var animationsStarted = false
private var displayLink: CADisplayLink?
private var countStartTime: CFTimeInterval = 0
private var correctCount: Int { total - wrongWords.count }
private var accuracy: Double { total > 0 ? Double(correctCount) / Double(total) : 0 }
private var durationText: String {
let seconds = Int(duration)
@@ -32,6 +41,56 @@ final class QuizResultView: UIView {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() {
super.layoutSubviews()
guard ringView.bounds.width > 0 else { return }
ringTrackLayer.path = ringPath()
ringProgressLayer.path = ringPath()
}
override func didMoveToWindow() {
super.didMoveToWindow()
guard window != nil, !animationsStarted else { return }
animationsStarted = true
DispatchQueue.main.async { [weak self] in
self?.startAnimations()
}
}
private func ringPath() -> CGPath {
let lineWidth = ringTrackLayer.lineWidth
let radius = min(ringView.bounds.width, ringView.bounds.height) / 2 - lineWidth / 2
let center = CGPoint(x: ringView.bounds.midX, y: ringView.bounds.midY)
return UIBezierPath(arcCenter: center, radius: radius,
startAngle: -.pi / 2, endAngle: .pi * 1.5, clockwise: true).cgPath
}
private func startAnimations() {
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.fromValue = 0
animation.toValue = accuracy
animation.duration = 0.8
animation.beginTime = CACurrentMediaTime() + 0.15
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
ringProgressLayer.add(animation, forKey: "progress")
countStartTime = CACurrentMediaTime() + 0.15
let link = CADisplayLink(target: self, selector: #selector(countTick(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
}
@objc private func countTick(_ link: CADisplayLink) {
let progress = max(0, min(1, (CACurrentMediaTime() - countStartTime) / 0.6))
scoreLabel.text = "\(Int((Double(correctCount) * progress).rounded()))/\(total)"
if progress >= 1 {
link.invalidate()
displayLink = nil
}
}
private func buildLayout() {
let scrollView = UIScrollView()
addSubview(scrollView)
@@ -49,28 +108,14 @@ final class QuizResultView: UIView {
scrollView.addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 24),
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 32),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -24),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
])
let scoreLabel = UILabel()
scoreLabel.text = "\(correctCount) / \(total)"
scoreLabel.font = .systemFont(ofSize: 44, weight: .bold)
scoreLabel.textAlignment = .center
let durationLabel = UILabel()
durationLabel.text = "用时 \(durationText)"
durationLabel.font = .preferredFont(forTextStyle: .subheadline)
durationLabel.textColor = .secondaryLabel
durationLabel.textAlignment = .center
let scoreStack = UIStackView(arrangedSubviews: [scoreLabel, durationLabel])
scoreStack.axis = .vertical
scoreStack.spacing = 8
stack.addArrangedSubview(scoreStack)
stack.addArrangedSubview(makeScoreSection())
if !wrongWords.isEmpty {
stack.addArrangedSubview(makeWrongWordsSection())
@@ -88,62 +133,127 @@ final class QuizResultView: UIView {
stack.addArrangedSubview(buttonStack)
}
private func makeScoreSection() -> UIView {
ringTrackLayer.fillColor = UIColor.clear.cgColor
ringTrackLayer.strokeColor = UIColor.tertiarySystemFill.cgColor
ringTrackLayer.lineWidth = 10
ringProgressLayer.fillColor = UIColor.clear.cgColor
ringProgressLayer.strokeColor = Theme.Color.accent.cgColor
ringProgressLayer.lineWidth = 10
ringProgressLayer.lineCap = .round
ringProgressLayer.strokeEnd = 0
ringView.layer.addSublayer(ringTrackLayer)
ringView.layer.addSublayer(ringProgressLayer)
ringView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
ringView.widthAnchor.constraint(equalToConstant: 132),
ringView.heightAnchor.constraint(equalToConstant: 132)
])
scoreLabel.text = "0/\(total)"
scoreLabel.font = Theme.Font.rounded(size: 26, weight: .bold)
scoreLabel.textAlignment = .center
ringView.addSubview(scoreLabel)
scoreLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scoreLabel.centerXAnchor.constraint(equalTo: ringView.centerXAnchor),
scoreLabel.centerYAnchor.constraint(equalTo: ringView.centerYAnchor)
])
let captionLabel = UILabel()
captionLabel.text = "答对 \(correctCount) 题 · 用时 \(durationText)"
captionLabel.font = .preferredFont(forTextStyle: .subheadline)
captionLabel.textColor = .secondaryLabel
captionLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [ringView, captionLabel])
stack.axis = .vertical
stack.spacing = 16
stack.alignment = .center
return stack
}
private func makeWrongWordsSection() -> UIView {
let titleLabel = UILabel()
titleLabel.text = "错题(\(wrongWords.count)"
titleLabel.font = .preferredFont(forTextStyle: .headline)
let section = UIStackView(arrangedSubviews: [titleLabel])
let card = UIView()
card.backgroundColor = Theme.Color.cardBackground
card.layer.cornerRadius = Theme.Metrics.cardRadius
let cardStack = UIStackView()
cardStack.axis = .vertical
card.addSubview(cardStack)
cardStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
cardStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4),
cardStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
cardStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
cardStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4)
])
for (index, word) in wrongWords.enumerated() {
cardStack.addArrangedSubview(makeWrongWordRow(word))
if index < wrongWords.count - 1 {
cardStack.addArrangedSubview(HairlineView())
}
}
let section = UIStackView(arrangedSubviews: [titleLabel, card])
section.axis = .vertical
section.spacing = 8
for word in wrongWords {
let button = UIButton(type: .system)
var config = UIButton.Configuration.plain()
config.contentInsets = .init(top: 8, leading: 12, bottom: 8, trailing: 12)
button.configuration = config
button.contentHorizontalAlignment = .fill
let wordLabel = UILabel()
wordLabel.text = word.word
wordLabel.font = .preferredFont(forTextStyle: .headline)
let meaningLabel = UILabel()
meaningLabel.text = word.meaning
meaningLabel.textColor = .secondaryLabel
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
chevron.tintColor = .tertiaryLabel
chevron.setContentHuggingPriority(.required, for: .horizontal)
let row = UIStackView(arrangedSubviews: [wordLabel, UIView(), meaningLabel, chevron])
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
row.isUserInteractionEnabled = false
button.addSubview(row)
row.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: button.topAnchor, constant: 8),
row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -8)
])
button.addAction(UIAction { [weak self] _ in
self?.onSelectWord(word)
}, for: .touchUpInside)
section.addArrangedSubview(button)
let divider = HairlineView()
section.addArrangedSubview(divider)
}
return section
}
private func makeWrongWordRow(_ word: Word) -> UIView {
let button = UIButton(type: .system)
var config = UIButton.Configuration.plain()
config.contentInsets = .init(top: 10, leading: 2, bottom: 10, trailing: 2)
button.configuration = config
button.contentHorizontalAlignment = .fill
let wordLabel = UILabel()
wordLabel.text = word.word
wordLabel.font = .preferredFont(forTextStyle: .headline)
let meaningLabel = UILabel()
meaningLabel.text = word.meaning
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
meaningLabel.textColor = .secondaryLabel
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
chevron.tintColor = .tertiaryLabel
chevron.setContentHuggingPriority(.required, for: .horizontal)
let row = UIStackView(arrangedSubviews: [wordLabel, UIView(), meaningLabel, chevron])
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
row.isUserInteractionEnabled = false
button.addSubview(row)
row.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 2),
row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -2),
row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
])
button.addAction(UIAction { [weak self] _ in
self?.onSelectWord(word)
}, for: .touchUpInside)
return button
}
private func makeButton(title: String, prominent: Bool, action: @escaping () -> Void) -> UIButton {
let button = UIButton(type: .system)
var config = prominent ? UIButton.Configuration.borderedProminent() : UIButton.Configuration.plain()
config.title = title
config.contentInsets = .init(top: 12, leading: 16, bottom: 12, trailing: 16)
if prominent {
config.background.cornerRadius = Theme.Metrics.controlRadius
}
button.configuration = config
button.addAction(UIAction { _ in action() }, for: .touchUpInside)
return button
@@ -1,11 +1,29 @@
import UIKit
final class QuizSetupViewController: UITableViewController {
final class QuizSetupViewController: UIViewController {
private struct ModeItem {
let mode: QuizMode
let detail: String
let icon: String
}
private let request: QuizSetupRequest
private let items: [ModeItem] = [
ModeItem(mode: .enToZh, detail: "看英文选中文释义", icon: "character"),
ModeItem(mode: .zhToEn, detail: "看中文选英文单词", icon: "character.bubble"),
ModeItem(mode: .spelling, detail: "补全单词空缺字母", icon: "keyboard"),
ModeItem(mode: .dictation, detail: "根据释义默写全词", icon: "square.and.pencil")
]
private var selectedIndex: Int?
private var cardViews: [UIView] = []
private var checkViews: [UIImageView] = []
private let countControl = UISegmentedControl()
private let startButton = UIButton(type: .system)
init(request: QuizSetupRequest) {
self.request = request
super.init(style: .plain)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
@@ -13,32 +31,176 @@ final class QuizSetupViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = request.mode.rawValue
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}
title = "开始测验"
navigationItem.largeTitleDisplayMode = .never
view.backgroundColor = .systemBackground
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 }
let contentStack = UIStackView()
contentStack.axis = .vertical
contentStack.spacing = 12
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.textProperties.font = .preferredFont(forTextStyle: .headline)
content.secondaryTextProperties.color = .secondaryLabel
if indexPath.row == 0 {
content.text = "全部随机测试"
content.secondaryText = "\(request.words.count)"
} else {
content.text = "随机十个"
content.secondaryText = "从词库中随机抽取 10 题"
contentStack.addArrangedSubview(makeCaption("选择模式"))
for (index, item) in items.enumerated() {
let card = makeModeCard(item: item, index: index)
cardViews.append(card)
contentStack.addArrangedSubview(card)
}
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
return cell
contentStack.setCustomSpacing(24, after: cardViews.last!)
contentStack.addArrangedSubview(makeCaption("题量"))
setupCountControl()
contentStack.addArrangedSubview(countControl)
let scrollView = UIScrollView()
scrollView.addSubview(contentStack)
contentStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8),
contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
contentStack.bottomAnchor.constraint(lessThanOrEqualTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
])
var buttonConfig = UIButton.Configuration.borderedProminent()
buttonConfig.title = "开始"
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
buttonConfig.titleTextAttributesTransformer = .init { incoming in
var outgoing = incoming
outgoing.font = .preferredFont(forTextStyle: .headline)
return outgoing
}
startButton.configuration = buttonConfig
startButton.isEnabled = false
startButton.addAction(UIAction { [weak self] _ in self?.startQuiz() }, for: .touchUpInside)
view.addSubview(scrollView)
view.addSubview(startButton)
scrollView.translatesAutoresizingMaskIntoConstraints = false
startButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: startButton.topAnchor, constant: -12),
startButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
startButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
startButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12)
])
updateCards(animated: false)
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let words = indexPath.row == 0 ? request.words : Array(request.words.shuffled().prefix(10))
navigationController?.pushViewController(QuizViewController(config: QuizConfig(mode: request.mode, words: words)), animated: true)
private func makeCaption(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .preferredFont(forTextStyle: .caption1)
label.textColor = .secondaryLabel
return label
}
private func makeModeCard(item: ModeItem, index: Int) -> UIView {
let card = UIView()
card.tag = index
card.backgroundColor = Theme.Color.cardBackground
card.layer.cornerRadius = Theme.Metrics.cardRadius
card.layer.borderWidth = 1.5
card.layer.borderColor = UIColor.clear.cgColor
let tile = UIView()
tile.backgroundColor = Theme.Color.accent.withAlphaComponent(0.12)
tile.layer.cornerRadius = 10
let icon = UIImageView(image: UIImage(systemName: item.icon,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)))
icon.tintColor = Theme.Color.accent
tile.addSubview(icon)
tile.translatesAutoresizingMaskIntoConstraints = false
icon.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tile.widthAnchor.constraint(equalToConstant: 38),
tile.heightAnchor.constraint(equalToConstant: 38),
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor)
])
let titleLabel = UILabel()
titleLabel.text = item.mode.rawValue
titleLabel.font = .preferredFont(forTextStyle: .headline)
let detailLabel = UILabel()
detailLabel.text = item.detail
detailLabel.font = .preferredFont(forTextStyle: .caption1)
detailLabel.textColor = .secondaryLabel
let textStack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
textStack.axis = .vertical
textStack.spacing = 2
let check = UIImageView(image: UIImage(systemName: "circle",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)))
check.tintColor = .tertiaryLabel
check.setContentHuggingPriority(.required, for: .horizontal)
checkViews.append(check)
let row = UIStackView(arrangedSubviews: [tile, textStack, check])
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
row.isUserInteractionEnabled = false
card.addSubview(row)
row.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
row.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
row.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
row.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
])
card.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cardTapped(_:))))
return card
}
private func setupCountControl() {
countControl.insertSegment(withTitle: "全部 \(request.words.count)", at: 0, animated: false)
if request.words.count > 10 {
countControl.insertSegment(withTitle: "随机 10 题", at: 1, animated: false)
}
countControl.selectedSegmentIndex = 0
}
@objc private func cardTapped(_ gesture: UITapGestureRecognizer) {
guard let card = gesture.view, selectedIndex != card.tag else { return }
selectedIndex = card.tag
Haptics.selection()
updateCards(animated: true)
startButton.isEnabled = true
}
private func updateCards(animated: Bool) {
for (index, card) in cardViews.enumerated() {
let selected = index == selectedIndex
let apply = {
card.backgroundColor = selected ? Theme.Color.accent.withAlphaComponent(0.12) : Theme.Color.cardBackground
card.layer.borderColor = selected ? Theme.Color.accent.cgColor : UIColor.clear.cgColor
self.checkViews[index].image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
self.checkViews[index].tintColor = selected ? Theme.Color.accent : .tertiaryLabel
}
if animated {
UIView.animate(withDuration: 0.2, animations: apply)
} else {
apply()
}
}
}
private func startQuiz() {
guard let selectedIndex else { return }
Haptics.selection()
let words = countControl.selectedSegmentIndex == 0
? request.words
: Array(request.words.shuffled().prefix(10))
let config = QuizConfig(mode: items[selectedIndex].mode, words: words)
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
}
}
@@ -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() {
@@ -76,6 +76,26 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
refreshBoxes()
}
func showResultState(correct: Bool) {
let color = correct ? Theme.Color.correct : Theme.Color.wrong
for position in blankPositions {
boxViews[position].layer.borderColor = color.cgColor
}
}
private func popBox(atBlank blankIndex: Int) {
guard blankPositions.indices.contains(blankIndex) else { return }
let box = boxViews[blankPositions[blankIndex]]
UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.5,
initialSpringVelocity: 0, options: .allowUserInteraction) {
box.transform = CGAffineTransform(scaleX: 1.12, y: 1.12)
} completion: { _ in
UIView.animate(withDuration: 0.2) {
box.transform = .identity
}
}
}
@objc private func reactivate() {
activate()
}
@@ -95,6 +115,7 @@ final class SpellingInputView: UIView, UITextFieldDelegate {
filledBlanks[cursorIndex] = String(char).lowercased()
cursorIndex += 1
refreshBoxes()
popBox(atBlank: cursorIndex - 1)
onChange?()
}
textField.text = nil
@@ -0,0 +1,93 @@
import UIKit
final class WordCardCell: UITableViewCell {
private let cardView = UIView()
private let wordLabel = UILabel()
private let phoneticLabel = UILabel()
private let meaningLabel = UILabel()
private let speakerButton = UIButton(type: .system)
private var word: Word?
var onSpeak: ((Word) -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
contentView.backgroundColor = .clear
selectionStyle = .none
cardView.backgroundColor = Theme.Color.cardBackground
cardView.layer.cornerRadius = Theme.Metrics.cardRadius
contentView.addSubview(cardView)
cardView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
cardView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5),
cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
cardView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5)
])
wordLabel.font = .preferredFont(forTextStyle: .headline)
phoneticLabel.font = .preferredFont(forTextStyle: .caption1)
phoneticLabel.textColor = .tertiaryLabel
meaningLabel.font = .preferredFont(forTextStyle: .subheadline)
meaningLabel.textColor = .secondaryLabel
let titleRow = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, UIView()])
titleRow.axis = .horizontal
titleRow.alignment = .firstBaseline
titleRow.spacing = 6
let textStack = UIStackView(arrangedSubviews: [titleRow, meaningLabel])
textStack.axis = .vertical
textStack.spacing = 2
speakerButton.tintColor = Theme.Color.accent
speakerButton.addAction(UIAction { [weak self] _ in
guard let self, let word else { return }
onSpeak?(word)
}, for: .touchUpInside)
speakerButton.setContentHuggingPriority(.required, for: .horizontal)
let row = UIStackView(arrangedSubviews: [textStack, speakerButton])
row.axis = .horizontal
row.alignment = .center
row.spacing = 8
cardView.addSubview(row)
row.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
row.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10),
row.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 14),
row.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -8),
row.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10),
speakerButton.widthAnchor.constraint(equalToConstant: 44),
speakerButton.heightAnchor.constraint(equalToConstant: 44)
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
func configure(with word: Word) {
self.word = word
wordLabel.text = word.word
phoneticLabel.text = word.phonetic
meaningLabel.text = word.meaning
updateSpeakerState()
}
func updateSpeakerState() {
guard let word else { return }
let speaking = SpeechService.shared.isSpeaking(word.word)
let symbol = speaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill"
speakerButton.setImage(UIImage(systemName: symbol,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)),
for: .normal)
speakerButton.isEnabled = !speaking
}
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
super.setHighlighted(highlighted, animated: animated)
cardView.setPressed(highlighted, animated: animated)
}
}
@@ -5,7 +5,10 @@ final class WordDetailViewController: UIViewController {
private let speech = SpeechService.shared
private let speakButton = UIButton(type: .system)
private var isSpeaking: Bool { speech.speakingText == word.word }
private let exampleSpeakButton = UIButton(type: .system)
private let contentStack = UIStackView()
private var speechObserver: NSObjectProtocol?
private var didAnimateEntry = false
init(word: Word) {
self.word = word
@@ -15,28 +18,46 @@ final class WordDetailViewController: UIViewController {
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit {
if let speechObserver {
NotificationCenter.default.removeObserver(speechObserver)
}
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = word.word
navigationItem.largeTitleDisplayMode = .never
buildLayout()
updateSpeakButton()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
speech.onSpeakingChanged = { [weak self] in
self?.updateSpeakButton()
updateSpeakState()
speechObserver = NotificationCenter.default.addObserver(
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
) { [weak self] _ in
self?.updateSpeakState()
}
}
private func updateSpeakButton() {
var config = speakButton.configuration
config?.title = isSpeaking ? "朗读中" : "发音"
config?.image = UIImage(systemName: isSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill")
speakButton.configuration = config
speakButton.isEnabled = !isSpeaking
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !didAnimateEntry {
didAnimateEntry = true
contentStack.animateEntry(distance: 16, duration: 0.35)
}
}
private func updateSpeakState() {
let wordSpeaking = speech.isSpeaking(word.word)
speakButton.configuration?.image = UIImage(
systemName: wordSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2.fill",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))
speakButton.isEnabled = !wordSpeaking
let exampleSpeaking = speech.isSpeaking(word.example)
exampleSpeakButton.configuration?.image = UIImage(
systemName: exampleSpeaking ? "speaker.wave.3.fill" : "speaker.wave.2",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))
exampleSpeakButton.isEnabled = !exampleSpeaking
}
private func buildLayout() {
@@ -50,55 +71,67 @@ final class WordDetailViewController: UIViewController {
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 32
scrollView.addSubview(stack)
stack.translatesAutoresizingMaskIntoConstraints = false
contentStack.axis = .vertical
contentStack.spacing = 32
scrollView.addSubview(contentStack)
contentStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 24),
contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16),
contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16),
contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16),
contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32)
])
stack.addArrangedSubview(makeHeader())
stack.addArrangedSubview(makeCard())
contentStack.addArrangedSubview(makeHeader())
contentStack.addArrangedSubview(makeCard())
}
private func makeHeader() -> UIView {
let wordLabel = UILabel()
wordLabel.text = word.word
wordLabel.font = .systemFont(ofSize: 48, weight: .bold)
wordLabel.font = Theme.Font.wordHero()
wordLabel.textAlignment = .center
let phoneticIcon = UIImageView(image: UIImage(
systemName: "speaker.wave.2",
withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .medium)))
phoneticIcon.tintColor = .secondaryLabel
let phoneticLabel = UILabel()
phoneticLabel.text = word.phonetic
phoneticLabel.font = .preferredFont(forTextStyle: .title3)
phoneticLabel.textColor = .secondaryLabel
phoneticLabel.textAlignment = .center
let phoneticRow = UIStackView(arrangedSubviews: [phoneticIcon, phoneticLabel])
phoneticRow.axis = .horizontal
phoneticRow.alignment = .center
phoneticRow.spacing = 6
var config = UIButton.Configuration.borderedProminent()
config.imagePadding = 6
config.contentInsets = .init(top: 10, leading: 24, bottom: 10, trailing: 24)
speakButton.configuration = config
var buttonConfig = UIButton.Configuration.filled()
buttonConfig.baseBackgroundColor = Theme.Color.accent
buttonConfig.baseForegroundColor = .white
buttonConfig.cornerStyle = .capsule
speakButton.configuration = buttonConfig
speakButton.addAction(UIAction { [weak self] _ in
guard let self else { return }
speech.speak(word.word)
}, for: .touchUpInside)
speakButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
speakButton.widthAnchor.constraint(equalToConstant: 56),
speakButton.heightAnchor.constraint(equalToConstant: 56)
])
let stack = UIStackView(arrangedSubviews: [wordLabel, phoneticLabel, speakButton])
let stack = UIStackView(arrangedSubviews: [wordLabel, phoneticRow, speakButton])
stack.axis = .vertical
stack.spacing = 12
stack.spacing = 16
stack.alignment = .center
return stack
}
private func makeCard() -> UIView {
let card = UIView()
card.backgroundColor = .quaternarySystemFill.withAlphaComponent(0.5)
card.layer.cornerRadius = 16
card.backgroundColor = Theme.Color.cardBackground
card.layer.cornerRadius = Theme.Metrics.cardRadius
let meaningTitle = makeCaption("释义")
let meaningLabel = UILabel()
@@ -118,8 +151,9 @@ final class WordDetailViewController: UIViewController {
exampleMeaningLabel.textColor = .secondaryLabel
exampleMeaningLabel.numberOfLines = 0
let exampleSpeakButton = UIButton(type: .system)
exampleSpeakButton.setImage(UIImage(systemName: "speaker.wave.2"), for: .normal)
var ghostConfig = UIButton.Configuration.plain()
ghostConfig.baseForegroundColor = Theme.Color.accent
exampleSpeakButton.configuration = ghostConfig
exampleSpeakButton.addAction(UIAction { [weak self] _ in
guard let self else { return }
speech.speak(word.example)
@@ -1,60 +1,153 @@
import UIKit
final class WordListViewController: UITableViewController {
final class WordListViewController: UIViewController {
private let category: WordCategory
private let colorIndex: Int
private let tableView = UITableView(frame: .zero, style: .plain)
private let ctaBackground = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
private let quizButton = UIButton(type: .system)
private var speechObserver: NSObjectProtocol?
init(category: WordCategory) {
init(category: WordCategory, colorIndex: Int) {
self.category = category
super.init(style: .plain)
self.colorIndex = colorIndex
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
deinit {
if let speechObserver {
NotificationCenter.default.removeObserver(speechObserver)
}
}
override func viewDidLoad() {
super.viewDidLoad()
title = category.name
navigationItem.largeTitleDisplayMode = .never
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
view.backgroundColor = .systemBackground
let quizMenu = UIMenu(title: "", children: [
UIAction(title: QuizMode.enToZh.rawValue) { [weak self] _ in self?.startQuiz(.enToZh) },
UIAction(title: QuizMode.zhToEn.rawValue) { [weak self] _ in self?.startQuiz(.zhToEn) },
UIAction(title: QuizMode.spelling.rawValue) { [weak self] _ in self?.startQuiz(.spelling) },
UIAction(title: QuizMode.dictation.rawValue) { [weak self] _ in self?.startQuiz(.dictation) }
tableView.dataSource = self
tableView.delegate = self
tableView.register(WordCardCell.self, forCellReuseIdentifier: "cell")
tableView.separatorStyle = .none
tableView.backgroundColor = .systemBackground
tableView.tableHeaderView = makeHeader()
tableView.contentInset.bottom = 92
tableView.verticalScrollIndicatorInsets.bottom = 92
var buttonConfig = UIButton.Configuration.borderedProminent()
buttonConfig.title = "开始测验"
buttonConfig.image = UIImage(systemName: "pencil.and.list.clipboard")
buttonConfig.imagePadding = 6
buttonConfig.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
buttonConfig.background.cornerRadius = Theme.Metrics.controlRadius
buttonConfig.titleTextAttributesTransformer = .init { incoming in
var outgoing = incoming
outgoing.font = .preferredFont(forTextStyle: .headline)
return outgoing
}
quizButton.configuration = buttonConfig
quizButton.addAction(UIAction { [weak self] _ in self?.startQuiz() }, for: .touchUpInside)
view.addSubview(tableView)
view.addSubview(ctaBackground)
ctaBackground.contentView.addSubview(quizButton)
tableView.translatesAutoresizingMaskIntoConstraints = false
ctaBackground.translatesAutoresizingMaskIntoConstraints = false
quizButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
ctaBackground.leadingAnchor.constraint(equalTo: view.leadingAnchor),
ctaBackground.trailingAnchor.constraint(equalTo: view.trailingAnchor),
ctaBackground.bottomAnchor.constraint(equalTo: view.bottomAnchor),
quizButton.topAnchor.constraint(equalTo: ctaBackground.contentView.topAnchor, constant: 12),
quizButton.leadingAnchor.constraint(equalTo: ctaBackground.contentView.leadingAnchor, constant: 16),
quizButton.trailingAnchor.constraint(equalTo: ctaBackground.contentView.trailingAnchor, constant: -16),
quizButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12)
])
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "测验", menu: quizMenu)
}
private func startQuiz(_ mode: QuizMode) {
if category.words.count <= 10 {
let config = QuizConfig(mode: mode, words: category.words)
navigationController?.pushViewController(QuizViewController(config: config), animated: true)
} else {
let request = QuizSetupRequest(mode: mode, words: category.words)
navigationController?.pushViewController(QuizSetupViewController(request: request), animated: true)
speechObserver = NotificationCenter.default.addObserver(
forName: SpeechService.speakingDidChangeNotification, object: nil, queue: .main
) { [weak self] _ in
self?.updateVisibleSpeakers()
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
private func makeHeader() -> UIView {
let color = Theme.Color.category(at: colorIndex)
let header = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 56))
let tile = UIView()
tile.backgroundColor = color.withAlphaComponent(0.12)
tile.layer.cornerRadius = 10
let icon = UIImageView(image: UIImage(systemName: category.icon,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)))
icon.tintColor = color
icon.contentMode = .scaleAspectFit
tile.addSubview(icon)
header.addSubview(tile)
tile.translatesAutoresizingMaskIntoConstraints = false
icon.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel()
label.text = "\(category.words.count) 个单词"
label.font = .preferredFont(forTextStyle: .subheadline)
label.textColor = .secondaryLabel
header.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tile.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 16),
tile.centerYAnchor.constraint(equalTo: header.centerYAnchor),
tile.widthAnchor.constraint(equalToConstant: 32),
tile.heightAnchor.constraint(equalToConstant: 32),
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
label.leadingAnchor.constraint(equalTo: tile.trailingAnchor, constant: 10),
label.centerYAnchor.constraint(equalTo: header.centerYAnchor)
])
return header
}
private func startQuiz() {
Haptics.selection()
let request = QuizSetupRequest(words: category.words)
navigationController?.pushViewController(QuizSetupViewController(request: request), animated: true)
}
private func updateVisibleSpeakers() {
for cell in tableView.visibleCells.compactMap({ $0 as? WordCardCell }) {
cell.updateSpeakerState()
}
}
}
extension WordListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
category.words.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! WordCardCell
let word = category.words[indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var content = cell.defaultContentConfiguration()
content.text = word.word
content.textProperties.font = .preferredFont(forTextStyle: .headline)
content.secondaryText = word.meaning
content.secondaryTextProperties.color = .secondaryLabel
cell.contentConfiguration = content
cell.accessoryType = .disclosureIndicator
cell.configure(with: word)
cell.onSpeak = { word in
SpeechService.shared.speak(word.word)
}
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
navigationController?.pushViewController(WordDetailViewController(word: category.words[indexPath.row]), animated: true)
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
Haptics.selection()
navigationController?.pushViewController(
WordDetailViewController(word: category.words[indexPath.row]), animated: true)
}
}