0df02d487e
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
305 lines
11 KiB
Swift
305 lines
11 KiB
Swift
import UIKit
|
|
|
|
final class SpellingInputView: UIView, UITextFieldDelegate {
|
|
var onChange: (() -> Void)?
|
|
var onSubmit: (() -> Void)?
|
|
private(set) var confirmButton: UIButton?
|
|
|
|
private let hiddenField = UITextField()
|
|
private let stack = UIStackView()
|
|
private let scrollView = UIScrollView()
|
|
|
|
private var chars: [String] = []
|
|
private var blankPositions: [Int] = []
|
|
private var filledBlanks: [String] = []
|
|
private var cursorIndex = 0
|
|
private var boxViews: [UIView] = []
|
|
private var cursorView: UIView?
|
|
private var inputEnabled = true
|
|
|
|
var isComplete: Bool {
|
|
!filledBlanks.isEmpty && filledBlanks.allSatisfy { !$0.isEmpty }
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
scrollView.showsHorizontalScrollIndicator = false
|
|
scrollView.showsVerticalScrollIndicator = false
|
|
scrollView.clipsToBounds = false
|
|
addSubview(scrollView)
|
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
scrollView.topAnchor.constraint(equalTo: topAnchor),
|
|
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
|
])
|
|
|
|
stack.axis = .horizontal
|
|
stack.spacing = 6
|
|
stack.alignment = .center
|
|
stack.isUserInteractionEnabled = true
|
|
scrollView.addSubview(stack)
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
|
stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
|
|
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
|
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
|
stack.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor)
|
|
])
|
|
|
|
hiddenField.delegate = self
|
|
hiddenField.autocorrectionType = .no
|
|
hiddenField.autocapitalizationType = .none
|
|
hiddenField.spellCheckingType = .no
|
|
hiddenField.keyboardType = .asciiCapable
|
|
hiddenField.returnKeyType = .done
|
|
hiddenField.frame = .zero
|
|
addSubview(hiddenField)
|
|
|
|
let accessory = UIView()
|
|
accessory.frame = CGRect(x: 0, y: 0, width: 0, height: 74)
|
|
accessory.autoresizingMask = .flexibleWidth
|
|
accessory.backgroundColor = .clear
|
|
|
|
var glassConfig = UIButton.Configuration.glass()
|
|
glassConfig.title = "确认"
|
|
glassConfig.cornerStyle = .fixed
|
|
glassConfig.background.cornerRadius = 25
|
|
glassConfig.baseForegroundColor = .label
|
|
glassConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
|
var outgoing = incoming
|
|
outgoing.font = .systemFont(ofSize: 16, weight: .semibold)
|
|
return outgoing
|
|
}
|
|
let glassButton = UIButton(configuration: glassConfig)
|
|
confirmButton = glassButton
|
|
glassButton.addAction(UIAction { [weak self] _ in
|
|
self?.onSubmit?()
|
|
}, for: .touchUpInside)
|
|
glassButton.translatesAutoresizingMaskIntoConstraints = false
|
|
accessory.addSubview(glassButton)
|
|
NSLayoutConstraint.activate([
|
|
glassButton.trailingAnchor.constraint(equalTo: accessory.trailingAnchor, constant: -15),
|
|
glassButton.topAnchor.constraint(equalTo: accessory.topAnchor, constant: 12),
|
|
glassButton.widthAnchor.constraint(equalToConstant: 80),
|
|
glassButton.heightAnchor.constraint(equalToConstant: 50)
|
|
])
|
|
hiddenField.inputAccessoryView = accessory
|
|
|
|
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reactivate)))
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
func configure(blankedWord: String, blankPositions: [Int]) {
|
|
chars = blankedWord.map { String($0) }
|
|
self.blankPositions = blankPositions
|
|
filledBlanks = Array(repeating: "", count: blankPositions.count)
|
|
cursorIndex = 0
|
|
inputEnabled = true
|
|
rebuildBoxes()
|
|
}
|
|
|
|
func typedWord() -> String {
|
|
var rebuilt = chars
|
|
for (i, pos) in blankPositions.enumerated() {
|
|
rebuilt[pos] = filledBlanks[i]
|
|
}
|
|
return rebuilt.joined().lowercased()
|
|
}
|
|
|
|
func activate() {
|
|
guard inputEnabled, !hiddenField.isFirstResponder else { return }
|
|
hiddenField.becomeFirstResponder()
|
|
}
|
|
|
|
func deactivate() {
|
|
inputEnabled = false
|
|
hiddenField.resignFirstResponder()
|
|
refreshBoxes()
|
|
}
|
|
|
|
func freezeInput() {
|
|
inputEnabled = false
|
|
refreshBoxes()
|
|
}
|
|
|
|
func hideAccessoryView() {
|
|
confirmButton?.isHidden = true
|
|
hiddenField.inputAccessoryView = nil
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// MARK: - UITextFieldDelegate
|
|
|
|
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
guard inputEnabled else { return false }
|
|
if string.isEmpty {
|
|
if cursorIndex > 0 {
|
|
cursorIndex -= 1
|
|
filledBlanks[cursorIndex] = ""
|
|
refreshBoxes()
|
|
onChange?()
|
|
}
|
|
} else if let char = string.last, char.isLetter, cursorIndex < blankPositions.count {
|
|
filledBlanks[cursorIndex] = String(char).lowercased()
|
|
cursorIndex += 1
|
|
refreshBoxes()
|
|
popBox(atBlank: cursorIndex - 1)
|
|
onChange?()
|
|
}
|
|
textField.text = nil
|
|
return false
|
|
}
|
|
|
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
guard isComplete else { return false }
|
|
onSubmit?()
|
|
return false
|
|
}
|
|
|
|
// MARK: - Boxes
|
|
|
|
private func rebuildBoxes() {
|
|
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
let boxWidth: CGFloat = 32
|
|
let fontSize: CGFloat = 20
|
|
stack.spacing = 6
|
|
boxViews = chars.enumerated().map { index, ch in
|
|
let box = makeBox(fontSize: fontSize)
|
|
box.tag = index
|
|
if ch != "_", let label = box.viewWithTag(100) as? UILabel {
|
|
label.text = ch
|
|
label.isHidden = false
|
|
}
|
|
box.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(boxTapped(_:))))
|
|
stack.addArrangedSubview(box)
|
|
NSLayoutConstraint.activate([
|
|
box.widthAnchor.constraint(equalToConstant: boxWidth),
|
|
box.heightAnchor.constraint(equalToConstant: 44)
|
|
])
|
|
return box
|
|
}
|
|
refreshBoxes()
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.updateAlignment()
|
|
}
|
|
}
|
|
|
|
private func updateAlignment() {
|
|
let totalWidth = CGFloat(chars.count) * 32 + CGFloat(max(chars.count - 1, 0)) * 6
|
|
let available = scrollView.bounds.width
|
|
if totalWidth <= available {
|
|
let inset = max(0, (available - totalWidth) / 2)
|
|
scrollView.contentInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
|
|
scrollView.isScrollEnabled = false
|
|
} else {
|
|
scrollView.contentInset = .zero
|
|
scrollView.isScrollEnabled = true
|
|
}
|
|
}
|
|
|
|
@objc private func boxTapped(_ gesture: UITapGestureRecognizer) {
|
|
guard inputEnabled, let box = gesture.view else { return }
|
|
let index = box.tag
|
|
guard chars[index] == "_", let blankIndex = blankPositions.firstIndex(of: index) else {
|
|
activate()
|
|
return
|
|
}
|
|
filledBlanks[blankIndex] = ""
|
|
cursorIndex = blankIndex
|
|
refreshBoxes()
|
|
onChange?()
|
|
activate()
|
|
}
|
|
|
|
private func makeBox(fontSize: CGFloat) -> UIView {
|
|
let box = UIView()
|
|
box.backgroundColor = .quaternarySystemFill.withAlphaComponent(0.15)
|
|
box.layer.cornerRadius = 8
|
|
box.layer.borderWidth = 1
|
|
box.layer.borderColor = UIColor.separator.cgColor
|
|
|
|
let label = UILabel()
|
|
label.font = .systemFont(ofSize: fontSize, weight: .bold)
|
|
label.isHidden = true
|
|
label.tag = 100
|
|
box.addSubview(label)
|
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
label.centerXAnchor.constraint(equalTo: box.centerXAnchor),
|
|
label.centerYAnchor.constraint(equalTo: box.centerYAnchor)
|
|
])
|
|
return box
|
|
}
|
|
|
|
private func refreshBoxes() {
|
|
cursorView?.removeFromSuperview()
|
|
cursorView = nil
|
|
|
|
for (index, box) in boxViews.enumerated() {
|
|
guard chars[index] == "_", let blankIndex = blankPositions.firstIndex(of: index) else {
|
|
continue
|
|
}
|
|
let label = box.viewWithTag(100) as? UILabel
|
|
let filled = filledBlanks[blankIndex]
|
|
label?.text = filled
|
|
label?.isHidden = filled.isEmpty
|
|
|
|
let isCurrent = inputEnabled && blankIndex == cursorIndex
|
|
box.layer.borderColor = UIColor.tintColor.cgColor
|
|
box.layer.borderWidth = isCurrent ? 2 : 1
|
|
|
|
if isCurrent, filled.isEmpty {
|
|
let cursor = UIView()
|
|
cursor.backgroundColor = .tintColor
|
|
cursor.isUserInteractionEnabled = false
|
|
box.addSubview(cursor)
|
|
cursor.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
cursor.centerXAnchor.constraint(equalTo: box.centerXAnchor),
|
|
cursor.centerYAnchor.constraint(equalTo: box.centerYAnchor),
|
|
cursor.widthAnchor.constraint(equalToConstant: 2),
|
|
cursor.heightAnchor.constraint(equalToConstant: 22)
|
|
])
|
|
cursorView = cursor
|
|
startBlinking(cursor)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startBlinking(_ cursor: UIView) {
|
|
cursor.alpha = 1
|
|
UIView.animate(withDuration: 0.6, delay: 0, options: [.repeat, .autoreverse, .allowUserInteraction]) {
|
|
cursor.alpha = 0
|
|
}
|
|
}
|
|
}
|