385 lines
15 KiB
Swift
385 lines
15 KiB
Swift
//
|
||
// 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
|
||
|
||
primaryAvatarView.setHeadPic(member.displayHeadPic)
|
||
nameLabel.text = member.showName.isEmpty ? "圈子成员" : member.showName
|
||
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()
|
||
}
|
||
|
||
/// 该人取消 SOS。返回 `false` 表示弹层已无人,应关闭。
|
||
@discardableResult
|
||
func removeMember(userId: String) -> Bool {
|
||
let trimmed = userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return true }
|
||
|
||
if trimmed == primaryMemberID {
|
||
guard let next = secondaryMembers.first else { return false }
|
||
applyPrimary(next)
|
||
removeSecondary(userId: next.user_id)
|
||
return true
|
||
}
|
||
|
||
if secondaryMembers.contains(where: { $0.user_id == trimmed }) {
|
||
removeSecondary(userId: trimmed)
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func applyPrimary(_ member: GroupMemberModel) {
|
||
primaryMemberID = member.user_id
|
||
primaryAvatarView.setHeadPic(member.displayHeadPic)
|
||
nameLabel.text = member.showName.isEmpty ? "圈子成员" : member.showName
|
||
}
|
||
|
||
private func removeSecondary(userId: String) {
|
||
secondaryMembers.removeAll { $0.user_id == userId }
|
||
secondaryAvatarViews[userId]?.removeFromSuperview()
|
||
secondaryAvatarViews.removeValue(forKey: userId)
|
||
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.setHeadPic(member.displayHeadPic)
|
||
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?()
|
||
}
|
||
}
|