// // 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 } }