diff --git a/LearnEnglish/LearnEnglish/Models/Quiz.swift b/LearnEnglish/LearnEnglish/Models/Quiz.swift index 5276dfe..b8ed3a7 100644 --- a/LearnEnglish/LearnEnglish/Models/Quiz.swift +++ b/LearnEnglish/LearnEnglish/Models/Quiz.swift @@ -8,7 +8,6 @@ enum QuizMode: String, Hashable { } struct QuizSetupRequest: Hashable { - let mode: QuizMode let words: [Word] } diff --git a/LearnEnglish/LearnEnglish/SceneDelegate.swift b/LearnEnglish/LearnEnglish/SceneDelegate.swift index 6b262e5..18b2646 100644 --- a/LearnEnglish/LearnEnglish/SceneDelegate.swift +++ b/LearnEnglish/LearnEnglish/SceneDelegate.swift @@ -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 } diff --git a/LearnEnglish/LearnEnglish/Services/SpeechService.swift b/LearnEnglish/LearnEnglish/Services/SpeechService.swift index 06ed606..bc980d3 100644 --- a/LearnEnglish/LearnEnglish/Services/SpeechService.swift +++ b/LearnEnglish/LearnEnglish/Services/SpeechService.swift @@ -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() } } diff --git a/LearnEnglish/LearnEnglish/Utilities/Haptics.swift b/LearnEnglish/LearnEnglish/Utilities/Haptics.swift new file mode 100644 index 0000000..89b766c --- /dev/null +++ b/LearnEnglish/LearnEnglish/Utilities/Haptics.swift @@ -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) + } +} diff --git a/LearnEnglish/LearnEnglish/Utilities/Theme.swift b/LearnEnglish/LearnEnglish/Utilities/Theme.swift new file mode 100644 index 0000000..680e463 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Utilities/Theme.swift @@ -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 + } + } +} diff --git a/LearnEnglish/LearnEnglish/Views/CategoryCardCell.swift b/LearnEnglish/LearnEnglish/Views/CategoryCardCell.swift new file mode 100644 index 0000000..82a98ba --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/CategoryCardCell.swift @@ -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) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/CategoryListViewController.swift b/LearnEnglish/LearnEnglish/Views/CategoryListViewController.swift index 9c23e3a..fa88a5a 100644 --- a/LearnEnglish/LearnEnglish/Views/CategoryListViewController.swift +++ b/LearnEnglish/LearnEnglish/Views/CategoryListViewController.swift @@ -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) } } diff --git a/LearnEnglish/LearnEnglish/Views/DictationInputView.swift b/LearnEnglish/LearnEnglish/Views/DictationInputView.swift index b968475..43ce9bf 100644 --- a/LearnEnglish/LearnEnglish/Views/DictationInputView.swift +++ b/LearnEnglish/LearnEnglish/Views/DictationInputView.swift @@ -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 + } } diff --git a/LearnEnglish/LearnEnglish/Views/QuizResultView.swift b/LearnEnglish/LearnEnglish/Views/QuizResultView.swift index 22a55e0..fb9f155 100644 --- a/LearnEnglish/LearnEnglish/Views/QuizResultView.swift +++ b/LearnEnglish/LearnEnglish/Views/QuizResultView.swift @@ -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 diff --git a/LearnEnglish/LearnEnglish/Views/QuizSetupViewController.swift b/LearnEnglish/LearnEnglish/Views/QuizSetupViewController.swift index a7a3ebc..0c4d723 100644 --- a/LearnEnglish/LearnEnglish/Views/QuizSetupViewController.swift +++ b/LearnEnglish/LearnEnglish/Views/QuizSetupViewController.swift @@ -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) } } diff --git a/LearnEnglish/LearnEnglish/Views/QuizViewController.swift b/LearnEnglish/LearnEnglish/Views/QuizViewController.swift index 6ea1a15..f48cb48 100644 --- a/LearnEnglish/LearnEnglish/Views/QuizViewController.swift +++ b/LearnEnglish/LearnEnglish/Views/QuizViewController.swift @@ -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() { diff --git a/LearnEnglish/LearnEnglish/Views/SpellingInputView.swift b/LearnEnglish/LearnEnglish/Views/SpellingInputView.swift index 85fa0db..9d5f706 100644 --- a/LearnEnglish/LearnEnglish/Views/SpellingInputView.swift +++ b/LearnEnglish/LearnEnglish/Views/SpellingInputView.swift @@ -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 diff --git a/LearnEnglish/LearnEnglish/Views/WordCardCell.swift b/LearnEnglish/LearnEnglish/Views/WordCardCell.swift new file mode 100644 index 0000000..7610764 --- /dev/null +++ b/LearnEnglish/LearnEnglish/Views/WordCardCell.swift @@ -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) + } +} diff --git a/LearnEnglish/LearnEnglish/Views/WordDetailViewController.swift b/LearnEnglish/LearnEnglish/Views/WordDetailViewController.swift index 01ed19b..629dafb 100644 --- a/LearnEnglish/LearnEnglish/Views/WordDetailViewController.swift +++ b/LearnEnglish/LearnEnglish/Views/WordDetailViewController.swift @@ -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) diff --git a/LearnEnglish/LearnEnglish/Views/WordListViewController.swift b/LearnEnglish/LearnEnglish/Views/WordListViewController.swift index 353df83..c4222dd 100644 --- a/LearnEnglish/LearnEnglish/Views/WordListViewController.swift +++ b/LearnEnglish/LearnEnglish/Views/WordListViewController.swift @@ -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) } }