引入设计系统,统一 UI 风格,增加拼写/默写结果状态反馈
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user