263 lines
11 KiB
Swift
263 lines
11 KiB
Swift
import UIKit
|
||
|
||
final class QuizResultView: UIView {
|
||
private let total: Int
|
||
private let wrongWords: [Word]
|
||
private let duration: TimeInterval
|
||
private let onRestart: () -> Void
|
||
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)
|
||
return seconds >= 60 ? "\(seconds / 60) 分 \(seconds % 60) 秒" : "\(seconds) 秒"
|
||
}
|
||
|
||
init(total: Int, wrongWords: [Word], duration: TimeInterval,
|
||
onRestart: @escaping () -> Void, onExit: @escaping () -> Void,
|
||
onSelectWord: @escaping (Word) -> Void) {
|
||
self.total = total
|
||
self.wrongWords = wrongWords
|
||
self.duration = duration
|
||
self.onRestart = onRestart
|
||
self.onExit = onExit
|
||
self.onSelectWord = onSelectWord
|
||
super.init(frame: .zero)
|
||
backgroundColor = .systemBackground
|
||
buildLayout()
|
||
}
|
||
|
||
@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()
|
||
scrollView.alwaysBounceVertical = true
|
||
addSubview(scrollView)
|
||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
scrollView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
|
||
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||
])
|
||
|
||
let stack = UIStackView()
|
||
stack.axis = .vertical
|
||
stack.spacing = 24
|
||
scrollView.addSubview(stack)
|
||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
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)
|
||
])
|
||
|
||
stack.addArrangedSubview(makeScoreSection())
|
||
|
||
if !wrongWords.isEmpty {
|
||
stack.addArrangedSubview(makeWrongWordsSection())
|
||
}
|
||
|
||
let restartButton = makeButton(title: "再来一轮", prominent: true) { [weak self] in
|
||
self?.onRestart()
|
||
}
|
||
let exitButton = makeButton(title: "返回", prominent: false) { [weak self] in
|
||
self?.onExit()
|
||
}
|
||
let buttonStack = UIStackView(arrangedSubviews: [restartButton, exitButton])
|
||
buttonStack.axis = .vertical
|
||
buttonStack.spacing = 12
|
||
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 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
|
||
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
|
||
}
|
||
}
|