Files
language4english/LearnEnglish/LearnEnglish/Views/QuizResultView.swift
T

263 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}