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

358 lines
14 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
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?()
}
}