jsdw_ios/QuickLocation/Section/Home/SOS/SOSAnimationViews.swift

620 lines
21 KiB
Swift

//
// SOSAnimationViews.swift
// QuickLocation
//
import UIKit
final class SOSPulseButtonView: UIView {
var onTap: (() -> Void)?
private let buttonImageView = UIImageView(image: UIImage(named: "SOS/sos_main_button"))
private let titleImageView = UIImageView(image: UIImage(named: "SOS/sos_title"))
private let handImageView = UIImageView(image: UIImage(named: "SOS/sos_tap_hand"))
private let tapButton = UIButton(type: .custom)
private let rippleLayers = [CAShapeLayer(), CAShapeLayer()]
private var shouldAnimate = true
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isAccessibilityElement = false
rippleLayers.forEach {
$0.fillColor = UIColor.clear.cgColor
$0.strokeColor = UIColor(hexStr: "#FF7378").cgColor
$0.lineWidth = 1.5
$0.opacity = 0
layer.addSublayer($0)
}
buttonImageView.contentMode = .scaleAspectFit
addSubview(buttonImageView)
titleImageView.contentMode = .scaleAspectFit
addSubview(titleImageView)
handImageView.contentMode = .scaleAspectFit
addSubview(handImageView)
tapButton.accessibilityLabel = "发送SOS"
tapButton.addTarget(self, action: #selector(handleTap), for: .touchUpInside)
addSubview(tapButton)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
CGSize(width: 345, height: 345)
}
override func layoutSubviews() {
super.layoutSubviews()
let side = min(bounds.width, bounds.height)
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let buttonSide = side * 0.74
buttonImageView.frame = CGRect(
x: center.x - buttonSide / 2,
y: center.y - buttonSide / 2,
width: buttonSide,
height: buttonSide
)
let rippleCenter = buttonImageView.center
let rippleStartRadius = buttonImageView.frame.width / 2
rippleLayers.forEach { rippleLayer in
rippleLayer.frame = bounds
rippleLayer.path = UIBezierPath(
arcCenter: rippleCenter,
radius: rippleStartRadius,
startAngle: 0,
endAngle: .pi * 2,
clockwise: true
).cgPath
}
let titleWidth = buttonSide * 0.49
let titleAspectRatio = titleImageView.image.map { $0.size.height / $0.size.width } ?? 0.32
let titleHeight = titleWidth * titleAspectRatio
let titleCenterY = handImageView.isHidden
? center.y
: center.y - buttonSide * 0.13
titleImageView.frame = CGRect(
x: center.x - titleWidth / 2,
y: titleCenterY - titleHeight / 2,
width: titleWidth,
height: titleHeight
)
let handSize = CGSize(width: side * 0.09, height: side * 0.14)
handImageView.frame = CGRect(
x: center.x - handSize.width / 2,
y: center.y + buttonSide * 0.13,
width: handSize.width,
height: handSize.height
)
tapButton.frame = bounds
}
override func didMoveToWindow() {
super.didMoveToWindow()
window == nil ? stopAnimations() : startAnimationsIfNeeded()
}
func setShowsHand(_ showsHand: Bool) {
handImageView.isHidden = !showsHand
setNeedsLayout()
if showsHand {
startAnimationsIfNeeded()
} else {
handImageView.layer.removeAnimation(forKey: "sos.hand.tap")
}
}
func setAnimationEnabled(_ enabled: Bool) {
shouldAnimate = enabled
enabled ? startAnimationsIfNeeded() : stopAnimations()
}
private func startAnimationsIfNeeded() {
guard shouldAnimate, window != nil, !UIAccessibility.isReduceMotionEnabled else {
stopAnimations()
return
}
let travelDuration = 1.35
let staggerDuration = travelDuration * 0.85
let cycleDuration = staggerDuration * Double(rippleLayers.count)
let travelProgress = NSNumber(value: travelDuration / cycleDuration)
let baseTime = CACurrentMediaTime()
for (index, rippleLayer) in rippleLayers.enumerated() {
guard rippleLayer.animation(forKey: "sos.ripple") == nil else { continue }
let rippleScale = CAKeyframeAnimation(keyPath: "transform.scale")
rippleScale.values = [1.0, 1.42, 1.42]
rippleScale.keyTimes = [0, travelProgress, 1]
let opacity = CAKeyframeAnimation(keyPath: "opacity")
opacity.values = [0.58, 0, 0]
opacity.keyTimes = [0, travelProgress, 1]
let rippleGroup = CAAnimationGroup()
rippleGroup.animations = [rippleScale, opacity]
rippleGroup.duration = cycleDuration
rippleGroup.beginTime = baseTime + Double(index) * staggerDuration
rippleGroup.repeatCount = Float.infinity
rippleGroup.isRemovedOnCompletion = false
rippleLayer.add(rippleGroup, forKey: "sos.ripple")
}
guard !handImageView.isHidden,
handImageView.layer.animation(forKey: "sos.hand.tap") == nil else { return }
let translation = CAKeyframeAnimation(keyPath: "transform.translation.y")
translation.values = [0, 5, 5, 0]
translation.keyTimes = [0, 0.35, 0.58, 1]
let handScale = CAKeyframeAnimation(keyPath: "transform.scale")
handScale.values = [1, 0.9, 0.9, 1]
handScale.keyTimes = [0, 0.35, 0.58, 1]
let handGroup = CAAnimationGroup()
handGroup.animations = [translation, handScale]
handGroup.duration = 1.15
handGroup.repeatCount = Float.infinity
handImageView.layer.add(handGroup, forKey: "sos.hand.tap")
}
private func stopAnimations() {
rippleLayers.forEach { $0.removeAnimation(forKey: "sos.ripple") }
handImageView.layer.removeAnimation(forKey: "sos.hand.tap")
}
@objc private func handleTap() {
onTap?()
}
}
final class SOSCountdownRingView: UIView {
var onCompleted: (() -> Void)?
private let trackLayer = CAShapeLayer()
private let progressMaskLayer = CAShapeLayer()
private let progressGradientLayer = CAGradientLayer()
private let rippleLayers = [CAShapeLayer(), CAShapeLayer()]
private let digitStack = UIStackView()
private let alarmContainer = UIView()
private let alarmRaysView = UIImageView(image: UIImage(named: "SOS/sos_alarm_rays"))
private let alarmIconView = UIImageView(image: UIImage(named: "SOS/sos_alarm_icon"))
private var countdownTimer: Timer?
private var deadline: Date?
private var duration: TimeInterval = 10
private var lastDisplayedValue = -1
private var completionDelivered = false
private var progress: CGFloat = 1
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
rippleLayers.forEach {
$0.fillColor = UIColor.clear.cgColor
$0.strokeColor = UIColor(hexStr: "#F4B5EE").cgColor
$0.lineWidth = 1.5
$0.opacity = 0
layer.addSublayer($0)
}
trackLayer.fillColor = UIColor.clear.cgColor
trackLayer.strokeColor = UIColor(hexStr: "#EEE8FF").cgColor
trackLayer.lineCap = .butt
layer.addSublayer(trackLayer)
progressGradientLayer.type = .conic
progressGradientLayer.colors = [
UIColor(hexStr: "#C9B7FF").cgColor,
UIColor(hexStr: "#9C5AFF").cgColor,
UIColor(hexStr: "#D72DD6").cgColor,
UIColor(hexStr: "#C9B7FF").cgColor
]
progressGradientLayer.locations = [0, 0.38, 0.76, 1]
progressGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
progressGradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
progressGradientLayer.mask = progressMaskLayer
layer.addSublayer(progressGradientLayer)
digitStack.axis = .horizontal
digitStack.alignment = .center
digitStack.distribution = .fill
digitStack.spacing = 2
addSubview(digitStack)
alarmIconView.contentMode = .scaleAspectFit
alarmRaysView.contentMode = .scaleAspectFit
alarmContainer.addSubview(alarmIconView)
alarmContainer.addSubview(alarmRaysView)
addSubview(alarmContainer)
render(progress: 1, value: 10)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
CGSize(width: 345, height: 345)
}
override func layoutSubviews() {
super.layoutSubviews()
let side = min(bounds.width, bounds.height)
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let ringDiameter = side * 0.64
let ringRadius = ringDiameter / 2
let lineWidth = max(18, side * 0.064)
rippleLayers.forEach { rippleLayer in
rippleLayer.frame = bounds
rippleLayer.path = UIBezierPath(
arcCenter: center,
radius: ringRadius + lineWidth / 2,
startAngle: 0,
endAngle: .pi * 2,
clockwise: true
).cgPath
}
let ringPath = UIBezierPath(
arcCenter: center,
radius: ringRadius,
startAngle: -.pi / 2,
endAngle: .pi * 1.5,
clockwise: true
)
trackLayer.frame = bounds
trackLayer.path = ringPath.cgPath
trackLayer.lineWidth = lineWidth
progressMaskLayer.frame = bounds
progressMaskLayer.path = ringPath.cgPath
progressMaskLayer.fillColor = UIColor.clear.cgColor
progressMaskLayer.strokeColor = UIColor.black.cgColor
progressMaskLayer.lineWidth = lineWidth
progressMaskLayer.lineCap = .butt
progressGradientLayer.frame = bounds
layoutDigitStack(at: center)
let alarmSide = side * 0.15
let alarmSize = CGSize(width: alarmSide, height: alarmSide)
alarmContainer.bounds = CGRect(origin: .zero, size: alarmSize)
alarmIconView.frame = alarmContainer.bounds
alarmRaysView.frame = alarmContainer.bounds
updateAlarmPosition(progress: progress)
}
func start(duration: TimeInterval = 10) {
stop(reset: false)
self.duration = max(0.1, duration)
deadline = Date().addingTimeInterval(self.duration)
completionDelivered = false
lastDisplayedValue = -1
render(progress: 1, value: Int(ceil(self.duration)))
startAlarmAnimation()
let timer = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
self?.updateCountdown()
}
RunLoop.main.add(timer, forMode: .common)
countdownTimer = timer
}
func stop(reset: Bool = true) {
countdownTimer?.invalidate()
countdownTimer = nil
deadline = nil
rippleLayers.forEach { $0.removeAnimation(forKey: "sos.countdown.ripple") }
alarmRaysView.layer.removeAnimation(forKey: "sos.alarm.rays")
alarmIconView.layer.removeAnimation(forKey: "sos.alarm.icon")
if reset {
completionDelivered = false
lastDisplayedValue = -1
render(progress: 1, value: 10)
}
}
private func updateCountdown() {
guard let deadline else { return }
let remaining = max(0, deadline.timeIntervalSinceNow)
let progress = CGFloat(min(1, remaining / duration))
let value = max(0, min(10, Int(ceil(remaining))))
render(progress: progress, value: value)
guard remaining <= 0, !completionDelivered else { return }
completionDelivered = true
countdownTimer?.invalidate()
countdownTimer = nil
self.deadline = nil
rippleLayers.forEach { $0.removeAnimation(forKey: "sos.countdown.ripple") }
alarmRaysView.layer.removeAnimation(forKey: "sos.alarm.rays")
alarmIconView.layer.removeAnimation(forKey: "sos.alarm.icon")
onCompleted?()
}
private func render(progress: CGFloat, value: Int) {
self.progress = progress
CATransaction.begin()
CATransaction.setDisableActions(true)
progressMaskLayer.strokeStart = 1 - progress
progressMaskLayer.strokeEnd = 1
CATransaction.commit()
updateAlarmPosition(progress: progress)
guard value != lastDisplayedValue else { return }
lastDisplayedValue = value
digitStack.arrangedSubviews.forEach {
digitStack.removeArrangedSubview($0)
$0.removeFromSuperview()
}
String(value).forEach { character in
let imageView = UIImageView(image: UIImage(named: "SOS/sos_countdown_\(character)"))
imageView.contentMode = .scaleAspectFit
imageView.widthAnchor.constraint(equalToConstant: 42).isActive = true
imageView.heightAnchor.constraint(equalToConstant: 58).isActive = true
digitStack.addArrangedSubview(imageView)
}
layoutDigitStack(at: CGPoint(x: bounds.midX, y: bounds.midY))
bringSubviewToFront(digitStack)
}
private func updateAlarmPosition(progress: CGFloat) {
guard bounds.width > 0, bounds.height > 0 else { return }
let side = min(bounds.width, bounds.height)
let radius = side * 0.64 / 2
let angle = -.pi / 2 + .pi * 2 * (1 - progress)
alarmContainer.center = CGPoint(
x: bounds.midX + cos(angle) * radius,
y: bounds.midY + sin(angle) * radius - 5
)
bringSubviewToFront(alarmContainer)
}
private func layoutDigitStack(at center: CGPoint) {
let digitWidth: CGFloat = 42
let digitHeight: CGFloat = 58
let count = digitStack.arrangedSubviews.count
let spacingWidth = CGFloat(max(0, count - 1)) * digitStack.spacing
digitStack.bounds = CGRect(
x: 0,
y: 0,
width: CGFloat(count) * digitWidth + spacingWidth,
height: digitHeight
)
digitStack.center = center
digitStack.setNeedsLayout()
digitStack.layoutIfNeeded()
}
private func startAlarmAnimation() {
guard !UIAccessibility.isReduceMotionEnabled else { return }
let travelDuration = 1.35
let staggerDuration = travelDuration * 0.85
let cycleDuration = staggerDuration * Double(rippleLayers.count)
let travelProgress = NSNumber(value: travelDuration / cycleDuration)
let baseTime = CACurrentMediaTime()
for (index, rippleLayer) in rippleLayers.enumerated() {
let rippleScale = CAKeyframeAnimation(keyPath: "transform.scale")
rippleScale.values = [1.0, 1.55, 1.55]
rippleScale.keyTimes = [0, travelProgress, 1]
let rippleOpacity = CAKeyframeAnimation(keyPath: "opacity")
rippleOpacity.values = [0.58, 0, 0]
rippleOpacity.keyTimes = [0, travelProgress, 1]
let rippleGroup = CAAnimationGroup()
rippleGroup.animations = [rippleScale, rippleOpacity]
rippleGroup.duration = cycleDuration
rippleGroup.beginTime = baseTime + Double(index) * staggerDuration
rippleGroup.repeatCount = .infinity
rippleGroup.isRemovedOnCompletion = false
rippleLayer.add(rippleGroup, forKey: "sos.countdown.ripple")
}
let opacity = CAKeyframeAnimation(keyPath: "opacity")
opacity.values = [0.25, 1, 0.25]
opacity.keyTimes = [0, 0.5, 1]
let scale = CAKeyframeAnimation(keyPath: "transform.scale")
scale.values = [0.88, 1.08, 0.88]
scale.keyTimes = [0, 0.5, 1]
let group = CAAnimationGroup()
group.animations = [opacity, scale]
group.duration = 0.62
group.repeatCount = .infinity
alarmRaysView.layer.add(group, forKey: "sos.alarm.rays")
let rotation = CAKeyframeAnimation(keyPath: "transform.rotation.z")
rotation.values = [-CGFloat.pi / 24, CGFloat.pi / 24, -CGFloat.pi / 24]
rotation.keyTimes = [0, 0.5, 1]
let iconGroup = CAAnimationGroup()
iconGroup.animations = [rotation]
iconGroup.duration = 0.62
iconGroup.repeatCount = .infinity
iconGroup.isRemovedOnCompletion = false
alarmIconView.layer.add(iconGroup, forKey: "sos.alarm.icon")
}
deinit {
countdownTimer?.invalidate()
}
}
final class SOSSlideCancelView: UIView {
enum Style {
case practiceCountdown
case countdown
case active
}
var onCompleted: (() -> Void)?
private let gradientLayer = CAGradientLayer()
private let titleLabel = UILabel()
private let thumbView = UIImageView(image: UIImage(named: "SOS/sos_slider_arrow"))
private var style: Style = .countdown
private var startX: CGFloat = 3
private var isDragging = false
private var isLocked = false
override init(frame: CGRect) {
super.init(frame: frame)
layer.insertSublayer(gradientLayer, at: 0)
clipsToBounds = true
titleLabel.text = "滑动取消sos"
titleLabel.textColor = .white
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
titleLabel.textAlignment = .center
addSubview(titleLabel)
thumbView.contentMode = .scaleAspectFit
thumbView.isUserInteractionEnabled = true
addSubview(thumbView)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
thumbView.addGestureRecognizer(pan)
isAccessibilityElement = true
accessibilityLabel = "滑动取消SOS"
accessibilityTraits = .adjustable
configure(style: .countdown)
}
convenience init(thumbImageName: String) {
self.init(frame: .zero)
thumbView.image = UIImage(named: thumbImageName)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
gradientLayer.frame = bounds
gradientLayer.cornerRadius = bounds.height / 2
titleLabel.frame = bounds
let thumbSide = max(0, bounds.height - 10)
thumbView.frame = CGRect(x: 10, y: 5, width: thumbSide, height: thumbSide)
}
func configure(style: Style) {
self.style = style
switch style {
case .practiceCountdown:
gradientLayer.colors = [
UIColor(hexStr: "#08ADEF").cgColor,
UIColor(hexStr: "#43C7F8").cgColor
]
case .countdown, .active:
gradientLayer.colors = [
UIColor(hexStr: "#293445").cgColor,
UIColor(hexStr: "#293445").cgColor
]
}
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
reset(animated: false)
}
func setInteractionEnabled(_ enabled: Bool) {
isLocked = !enabled
alpha = enabled ? 1 : 0.65
thumbView.isUserInteractionEnabled = enabled
}
func reset(animated: Bool = true) {
isLocked = false
isDragging = false
thumbView.isUserInteractionEnabled = true
alpha = 1
let changes = {
self.thumbView.frame.origin.x = 2
}
if animated {
UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut], animations: changes)
} else {
changes()
}
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard !isLocked else { return }
let maxX = max(2, bounds.width - thumbView.bounds.width - 2)
switch gesture.state {
case .began:
isDragging = true
startX = thumbView.frame.minX
case .changed:
let translation = gesture.translation(in: self)
thumbView.frame.origin.x = min(maxX, max(2, startX + translation.x))
case .ended, .cancelled, .failed:
isDragging = false
if thumbView.frame.minX >= maxX - 4 {
isLocked = true
thumbView.isUserInteractionEnabled = false
onCompleted?()
} else {
reset()
}
default:
break
}
}
}
final class SOSGradientButton: UIButton {
private let gradientLayer = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
gradientLayer.colors = [
UIColor(hexStr: "#05AEF2").cgColor,
UIColor(hexStr: "#42C5F7").cgColor
]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradientLayer, at: 0)
setTitleColor(.white, for: .normal)
titleLabel?.font = .systemFont(ofSize: 18, weight: .bold)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
layer.cornerRadius = bounds.height / 2
gradientLayer.cornerRadius = bounds.height / 2
clipsToBounds = true
}
}