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

385 lines
15 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.

//
// 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?()
}
}