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

635 lines
22 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.

//
// SOSAnimationViews.swift
// QuickLocation
//
import UIKit
enum SOSConfiguration {
static let countdownDuration: TimeInterval = 5
static let slideCancelCompletionRatio: CGFloat = 0.9
static var countdownSeconds: Int {
Int(ceil(countdownDuration))
}
static var countdownDescription: String {
"\(countdownSeconds)秒后会将你的SOS和位置发送到你的圈子和紧急联系人。"
}
}
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 = SOSConfiguration.countdownDuration
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: SOSConfiguration.countdownSeconds)
}
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 = SOSConfiguration.countdownDuration) {
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: SOSConfiguration.countdownSeconds)
}
}
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(Int(ceil(duration)), 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 minimumX: CGFloat = 2
let maxX = max(minimumX, bounds.width - thumbView.bounds.width - minimumX)
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(minimumX, startX + translation.x))
case .ended, .cancelled, .failed:
isDragging = false
let completionX = minimumX + (maxX - minimumX) * SOSConfiguration.slideCancelCompletionRatio
if thumbView.frame.minX >= completionX {
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
}
}