// // SOSReceiveAlertView.swift // QuickLocation // import UIKit /// Full-screen SOS alert shown above the currently visible application page. final class SOSReceiveAlertView: UIView { var onAcknowledged: (() -> Void)? private let gradientLayer = CAGradientLayer() private let primaryAvatarView = UIImageView() private let primaryAvatarRingView = UIView() private let alarmIconView = UIImageView(image: UIImage(named: "SOS/sos_receive_alarm")) private let nameLabel = UILabel() private let messageLabel = UILabel() private let titleImageView = UIImageView(image: UIImage(named: "SOS/sos_receive_title")) private let acknowledgeButton = UIButton(type: .custom) private let extraBadge = UILabel() private let rippleLayers = [CAShapeLayer(), CAShapeLayer()] private var primaryMemberID = "" private var secondaryMembers: [GroupMemberModel] = [] private var secondaryAvatarViews: [String: UIImageView] = [:] private var hasStartedAnimations = false private static let rippleKey = "sos.receive.ripple" private static let backgroundFlashKey = "sos.receive.background.flash" private static let alarmFlashKey = "sos.receive.alarm.flash" private static let secondaryOffsets: [CGPoint] = [ CGPoint(x: -112, y: -60), CGPoint(x: 118, y: 8), CGPoint(x: -124, y: 26), CGPoint(x: 108, y: 96), CGPoint(x: -105, y: 100), CGPoint(x: -60, y: -116), CGPoint(x: 55, y: -120), CGPoint(x: 0, y: 136) ] override init(frame: CGRect) { super.init(frame: frame) setupView() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds let side = min(bounds.width, 414) let avatarSide = min(side * 0.38, 150) let avatarCenter = CGPoint( x: bounds.midX, y: min(bounds.height * 0.35, 292) ) primaryAvatarRingView.bounds = CGRect(x: 0, y: 0, width: avatarSide, height: avatarSide) primaryAvatarRingView.center = avatarCenter primaryAvatarRingView.layer.cornerRadius = avatarSide / 2 primaryAvatarView.frame = primaryAvatarRingView.bounds.insetBy(dx: 7, dy: 7) primaryAvatarView.layer.cornerRadius = primaryAvatarView.bounds.height / 2 let alarmSide = min(max(avatarSide * 0.46, 58), 72) alarmIconView.bounds = CGRect(x: 0, y: 0, width: alarmSide, height: alarmSide) alarmIconView.center = CGPoint( x: avatarCenter.x + avatarSide * 0.43, y: avatarCenter.y - avatarSide * 0.47 ) let rippleCenter = avatarCenter let rippleRadius = avatarSide / 2 let rippleDiameter = rippleRadius * 2 rippleLayers.forEach { layer in layer.bounds = CGRect(x: 0, y: 0, width: rippleDiameter, height: rippleDiameter) layer.position = rippleCenter layer.path = UIBezierPath( arcCenter: CGPoint(x: rippleRadius, y: rippleRadius), radius: rippleRadius, startAngle: 0, endAngle: .pi * 2, clockwise: true ).cgPath } let buttonWidth = min(bounds.width - 60, 345) let buttonHeight: CGFloat = 56 let bottomInset = max(safeAreaInsets.bottom + 64, 64) acknowledgeButton.frame = CGRect( x: bounds.midX - buttonWidth / 2, y: bounds.height - bottomInset - buttonHeight, width: buttonWidth, height: buttonHeight ) let nameTop = min( avatarCenter.y + avatarSide / 2 + 104, max(avatarCenter.y + avatarSide / 2 + 48, acknowledgeButton.frame.minY - 190) ) nameLabel.frame = CGRect(x: 20, y: nameTop, width: bounds.width - 40, height: 40) messageLabel.frame = CGRect(x: 20, y: nameLabel.frame.maxY + 10, width: bounds.width - 40, height: 44) let titleWidth = min(bounds.width * 0.34, 128) let titleHeight = titleImageView.image.map { titleWidth * $0.size.height / $0.size.width } ?? 40 titleImageView.frame = CGRect( x: bounds.midX - titleWidth / 2, y: messageLabel.frame.maxY + 16, width: titleWidth, height: titleHeight ) let hasOverflow = secondaryMembers.count > Self.secondaryOffsets.count let visibleCount = hasOverflow ? Self.secondaryOffsets.count - 1 : min(secondaryMembers.count, Self.secondaryOffsets.count) let extraCount = hasOverflow ? secondaryMembers.count - visibleCount : 0 for (index, member) in secondaryMembers.enumerated() { guard let imageView = secondaryAvatarViews[member.user_id] else { continue } imageView.isHidden = index >= visibleCount guard index < Self.secondaryOffsets.count else { continue } let offset = Self.secondaryOffsets[index] imageView.bounds = CGRect(x: 0, y: 0, width: 44, height: 44) imageView.center = CGPoint(x: avatarCenter.x + offset.x, y: avatarCenter.y + offset.y) imageView.layer.cornerRadius = 22 } extraBadge.isHidden = extraCount == 0 if extraCount > 0 { let offset = Self.secondaryOffsets[Self.secondaryOffsets.count - 1] extraBadge.bounds = CGRect(x: 0, y: 0, width: 44, height: 44) extraBadge.center = CGPoint(x: avatarCenter.x + offset.x, y: avatarCenter.y + offset.y) extraBadge.text = "+\(extraCount)" } bringSubviewToFront(primaryAvatarRingView) bringSubviewToFront(alarmIconView) bringSubviewToFront(nameLabel) bringSubviewToFront(messageLabel) bringSubviewToFront(titleImageView) bringSubviewToFront(acknowledgeButton) } override func didMoveToWindow() { super.didMoveToWindow() if window == nil { stopAnimations() } else if !isHidden { startAnimationsIfNeeded() } } func show(primary member: GroupMemberModel) { primaryMemberID = member.user_id secondaryMembers.removeAll() secondaryAvatarViews.values.forEach { $0.removeFromSuperview() } secondaryAvatarViews.removeAll() extraBadge.isHidden = true let image = member.userIcon.size == .zero ? UIImage(named: "Common/default_avatar") : member.userIcon primaryAvatarView.image = image nameLabel.text = member.nick_name.isEmpty ? "圈子成员" : member.nick_name isHidden = false setNeedsLayout() layoutIfNeeded() startAnimationsIfNeeded() } func appendSecondary(_ member: GroupMemberModel) { guard !member.user_id.isEmpty, member.user_id != primaryMemberID, !secondaryMembers.contains(where: { $0.user_id == member.user_id }) else { return } secondaryMembers.append(member) let imageView = makeSecondaryAvatar(member) secondaryAvatarViews[member.user_id] = imageView addSubview(imageView) setNeedsLayout() } func dismiss() { stopAnimations() secondaryAvatarViews.values.forEach { $0.removeFromSuperview() } secondaryAvatarViews.removeAll() secondaryMembers.removeAll() primaryMemberID = "" removeFromSuperview() } private func setupView() { backgroundColor = .white isAccessibilityElement = false accessibilityViewIsModal = true gradientLayer.type = .radial gradientLayer.colors = [ UIColor.white.cgColor, UIColor(hexStr: "#FFF4F4").cgColor, UIColor(hexStr: "#FFB7BA").cgColor ] gradientLayer.locations = [0, 0.58, 1] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradientLayer, at: 0) rippleLayers.forEach { $0.fillColor = UIColor.clear.cgColor $0.strokeColor = UIColor(hexStr: "#FF7378").cgColor $0.lineWidth = 1.5 $0.opacity = 0 layer.addSublayer($0) } primaryAvatarRingView.backgroundColor = .white primaryAvatarRingView.layer.cornerRadius = 75 primaryAvatarRingView.layer.shadowColor = UIColor.black.withAlphaComponent(0.14).cgColor primaryAvatarRingView.layer.shadowOpacity = 1 primaryAvatarRingView.layer.shadowRadius = 8 primaryAvatarRingView.layer.shadowOffset = CGSize(width: 0, height: 3) addSubview(primaryAvatarRingView) primaryAvatarView.contentMode = .scaleAspectFill primaryAvatarView.clipsToBounds = true primaryAvatarView.layer.masksToBounds = true primaryAvatarRingView.addSubview(primaryAvatarView) alarmIconView.contentMode = .scaleAspectFit addSubview(alarmIconView) nameLabel.textColor = UIColor(hexStr: "#273246") nameLabel.font = .systemFont(ofSize: 28, weight: .regular) nameLabel.textAlignment = .center addSubview(nameLabel) messageLabel.text = "正在向您紧急求助" messageLabel.textColor = UIColor(hexStr: "#273246") messageLabel.font = .systemFont(ofSize: 32, weight: .regular) messageLabel.textAlignment = .center messageLabel.adjustsFontSizeToFitWidth = true messageLabel.minimumScaleFactor = 0.72 addSubview(messageLabel) titleImageView.contentMode = .scaleAspectFit addSubview(titleImageView) extraBadge.backgroundColor = UIColor(hexStr: "#FF5A5F") extraBadge.textColor = .white extraBadge.font = .systemFont(ofSize: 13, weight: .semibold) extraBadge.textAlignment = .center extraBadge.layer.cornerRadius = 22 extraBadge.layer.borderWidth = 3 extraBadge.layer.borderColor = UIColor.white.cgColor extraBadge.clipsToBounds = true extraBadge.isHidden = true addSubview(extraBadge) acknowledgeButton.backgroundColor = UIColor(hexStr: "#FF5050") acknowledgeButton.cornerRadius = 20 acknowledgeButton.setTitle("我知道了", for: .normal) acknowledgeButton.setTitleColor(.white, for: .normal) acknowledgeButton.titleLabel?.font = FontManager.boboBold(18) acknowledgeButton.addTarget(self, action: #selector(handleAcknowledged), for: .touchUpInside) acknowledgeButton.accessibilityLabel = "我知道了" addSubview(acknowledgeButton) } private func makeSecondaryAvatar(_ member: GroupMemberModel) -> UIImageView { let imageView = UIImageView() imageView.image = member.userIcon.size == .zero ? UIImage(named: "Common/default_avatar") : member.userIcon imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.layer.borderWidth = 4 imageView.layer.borderColor = UIColor.white.cgColor imageView.layer.shadowColor = UIColor.black.withAlphaComponent(0.18).cgColor imageView.layer.shadowOpacity = 1 imageView.layer.shadowRadius = 4 imageView.layer.shadowOffset = CGSize(width: 0, height: 2) return imageView } private func startAnimationsIfNeeded() { guard !hasStartedAnimations, window != nil, !UIAccessibility.isReduceMotionEnabled else { return } hasStartedAnimations = true let flash = CAKeyframeAnimation(keyPath: "opacity") flash.values = [0.42, 1.0, 0.42] flash.keyTimes = [0, 0.5, 1] flash.duration = 1.15 flash.repeatCount = .infinity flash.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) flash.isRemovedOnCompletion = false gradientLayer.add(flash, forKey: Self.backgroundFlashKey) let alarmOpacity = CAKeyframeAnimation(keyPath: "opacity") alarmOpacity.values = [1.0, 0.25, 1.0] alarmOpacity.keyTimes = [0, 0.5, 1] alarmOpacity.duration = 0.7 alarmOpacity.repeatCount = .infinity alarmOpacity.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) let alarmScale = CAKeyframeAnimation(keyPath: "transform.scale") alarmScale.values = [1.0, 1.08, 1.0] alarmScale.keyTimes = [0, 0.5, 1] alarmScale.duration = 0.7 alarmScale.repeatCount = .infinity alarmScale.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) let alarmGroup = CAAnimationGroup() alarmGroup.animations = [alarmOpacity, alarmScale] alarmGroup.duration = 0.7 alarmGroup.repeatCount = .infinity alarmIconView.layer.add(alarmGroup, forKey: Self.alarmFlashKey) 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 scale = CAKeyframeAnimation(keyPath: "transform.scale") scale.values = [1.0, 2.1, 2.1] scale.keyTimes = [0, travelProgress, 1] let opacity = CAKeyframeAnimation(keyPath: "opacity") opacity.values = [0.58, 0, 0] opacity.keyTimes = [0, travelProgress, 1] let group = CAAnimationGroup() group.animations = [scale, opacity] group.duration = cycleDuration group.beginTime = baseTime + Double(index) * staggerDuration group.repeatCount = .infinity group.isRemovedOnCompletion = false rippleLayer.add(group, forKey: Self.rippleKey) } } private func stopAnimations() { hasStartedAnimations = false gradientLayer.removeAnimation(forKey: Self.backgroundFlashKey) gradientLayer.opacity = 1 alarmIconView.layer.removeAnimation(forKey: Self.alarmFlashKey) rippleLayers.forEach { $0.removeAnimation(forKey: Self.rippleKey) } } @objc private func handleAcknowledged() { onAcknowledged?() } }