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