// // SOSView.swift // QuickLocation // import UIKit import SwiftyUserDefaults enum SOSPageState { case idle case countdown case sending case active } final class SOSView: UIView { var onCountdownCompleted: (() -> Void)? var onCancelActive: (() -> Void)? var onManageContacts: (() -> Void)? private(set) var state: SOSPageState = .idle let sosPracticeView = SOSPracticeView() private let activeBackgroundView = SOSActiveBackgroundView() private let navView = BaseNavigationView(title: "紧急求救") private let infoCard = UIView() private let idleView = UIView() private let countdownView = UIView() private let activeView = UIView() private let idlePulseView = SOSPulseButtonView() private let activePulseView = SOSPulseButtonView() private let countdownRingView = SOSCountdownRingView() private let countdownTitleLabel = UILabel() private let countdownBodyLabel = UILabel() private let countdownSlider = SOSSlideCancelView() private let activeSlider = SOSSlideCancelView() private let contactStack = UIStackView() private let contactScrollView = UIScrollView() private let contactPanel = UIView() private let emptyContactLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor(hexStr: "#F9F9F9") setupUI() setupActions() configureContacts([]) setState(.idle) let hasCompletedPractice = Defaults[\.sosIsPracticeList].contains(AppContextManager.shared.userId) sosPracticeView.isHidden = hasCompletedPractice } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setState(_ state: SOSPageState) { self.state = state idleView.isHidden = state != .idle countdownView.isHidden = state != .countdown && state != .sending activeView.isHidden = state != .active activeBackgroundView.isHidden = state != .active if state == .active { activeBackgroundView.startFlashing() } else { activeBackgroundView.stopFlashing() } switch state { case .idle: countdownRingView.stop() countdownSlider.configure(style: .countdown) countdownSlider.setInteractionEnabled(true) idlePulseView.setShowsHand(true) idlePulseView.setAnimationEnabled(true) idlePulseView.isUserInteractionEnabled = true activePulseView.setAnimationEnabled(false) case .countdown: idlePulseView.setAnimationEnabled(false) activePulseView.setAnimationEnabled(false) countdownTitleLabel.attributedText = Self.cancelTitle countdownBodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" countdownSlider.configure(style: .countdown) countdownSlider.setInteractionEnabled(true) countdownRingView.start(duration: 10) case .sending: countdownRingView.stop(reset: false) countdownTitleLabel.text = "正在发送" countdownTitleLabel.textColor = UIColor(hexStr: "#273246") countdownBodyLabel.text = "正在发送你的SOS和位置信息,请稍候。" countdownSlider.setInteractionEnabled(false) case .active: countdownRingView.stop() activeSlider.configure(style: .active) activeSlider.setInteractionEnabled(true) idlePulseView.setAnimationEnabled(false) activePulseView.setShowsHand(false) activePulseView.setAnimationEnabled(true) activePulseView.isUserInteractionEnabled = false } } func setActiveCancelEnabled(_ enabled: Bool) { activeSlider.setInteractionEnabled(enabled) if enabled { activeSlider.reset(animated: false) } } func configureContacts(_ contacts: [EmergencyContactModel]) { contactStack.arrangedSubviews.forEach { contactStack.removeArrangedSubview($0) $0.removeFromSuperview() } emptyContactLabel.isHidden = !contacts.isEmpty contactScrollView.isHidden = contacts.isEmpty guard !contacts.isEmpty else { return } contacts.forEach { contact in let card = SOSEmergencyContactCard() card.configure(contact) card.widthAnchor.constraint(equalToConstant: 100).isActive = true contactStack.addArrangedSubview(card) } } func stopTransientActivities() { guard state == .countdown else { return } countdownRingView.stop() setState(.idle) } private func setupUI() { addSubview(activeBackgroundView) activeBackgroundView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ activeBackgroundView.topAnchor.constraint(equalTo: topAnchor), activeBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), activeBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), activeBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) setupNavigation() setupInfoCard() addSubview(idleView) addSubview(countdownView) addSubview(activeView) [idleView, countdownView, activeView].forEach { $0.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ $0.topAnchor.constraint(equalTo: infoCard.bottomAnchor, constant: 6), $0.leadingAnchor.constraint(equalTo: leadingAnchor), $0.trailingAnchor.constraint(equalTo: trailingAnchor), $0.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } setupIdleView() setupCountdownView() setupActiveView() addSubview(sosPracticeView) sosPracticeView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ sosPracticeView.topAnchor.constraint(equalTo: navView.bottomAnchor), sosPracticeView.leadingAnchor.constraint(equalTo: leadingAnchor), sosPracticeView.trailingAnchor.constraint(equalTo: trailingAnchor), sosPracticeView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } private func setupNavigation() { addSubview(navView) navView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ navView.topAnchor.constraint(equalTo: topAnchor), navView.leadingAnchor.constraint(equalTo: leadingAnchor), navView.trailingAnchor.constraint(equalTo: trailingAnchor), navView.heightAnchor.constraint(equalToConstant: kNaviHeight) ]) navView.titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) let navigationOffset = CGAffineTransform(translationX: 0, y: -7) navView.titleLabel.transform = navigationOffset navView.backBtn.transform = navigationOffset let moreButton = UIButton(type: .custom) moreButton.setImage(UIImage(named: "SOS/sos_nav_more"), for: .normal) moreButton.widthAnchor.constraint(equalToConstant: 24).isActive = true moreButton.heightAnchor.constraint(equalToConstant: 24).isActive = true moreButton.transform = CGAffineTransform(a: 1.5, b: 0, c: 0, d: 1.5, tx: -7, ty: -7) moreButton.accessibilityLabel = "更多" navView.addRightButton(moreButton) } private func setupInfoCard() { infoCard.backgroundColor = UIColor.white.withAlphaComponent(0.93) infoCard.layer.cornerRadius = 14 infoCard.layer.shadowColor = UIColor.black.cgColor infoCard.layer.shadowOpacity = 0.035 infoCard.layer.shadowRadius = 10 infoCard.layer.shadowOffset = CGSize(width: 0, height: 4) addSubview(infoCard) infoCard.translatesAutoresizingMaskIntoConstraints = false let iconBackground = UIView() iconBackground.backgroundColor = UIColor(hexStr: "#FFECEE") iconBackground.layer.cornerRadius = 10 infoCard.addSubview(iconBackground) iconBackground.translatesAutoresizingMaskIntoConstraints = false let iconView = UIImageView(image: UIImage(named: "Home/sos")) iconView.contentMode = .scaleAspectFit iconView.isHidden = true iconBackground.addSubview(iconView) iconView.translatesAutoresizingMaskIntoConstraints = false let titleLabel = UILabel() titleLabel.text = "遇到紧急情况?一键发送求救信息" titleLabel.textColor = UIColor(hexStr: "#273246") titleLabel.font = .systemFont(ofSize: 12, weight: .semibold) infoCard.addSubview(titleLabel) titleLabel.translatesAutoresizingMaskIntoConstraints = false let subtitleLabel = UILabel() subtitleLabel.text = "我们将向您的紧急联系人发送包含您位置和求救信息的通知" subtitleLabel.textColor = UIColor(hexStr: "#888888") subtitleLabel.font = .systemFont(ofSize: 10, weight: .regular) subtitleLabel.adjustsFontSizeToFitWidth = true subtitleLabel.minimumScaleFactor = 0.8 infoCard.addSubview(subtitleLabel) subtitleLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ infoCard.topAnchor.constraint(equalTo: navView.bottomAnchor, constant: 10), infoCard.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 15), infoCard.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -15), infoCard.heightAnchor.constraint(equalToConstant: 48), iconBackground.leadingAnchor.constraint(equalTo: infoCard.leadingAnchor, constant: 10), iconBackground.centerYAnchor.constraint(equalTo: infoCard.centerYAnchor), iconBackground.widthAnchor.constraint(equalToConstant: 30), iconBackground.heightAnchor.constraint(equalToConstant: 30), iconView.centerXAnchor.constraint(equalTo: iconBackground.centerXAnchor), iconView.centerYAnchor.constraint(equalTo: iconBackground.centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: 24), iconView.heightAnchor.constraint(equalToConstant: 24), titleLabel.topAnchor.constraint(equalTo: infoCard.topAnchor, constant: 7), titleLabel.leadingAnchor.constraint(equalTo: iconBackground.trailingAnchor, constant: 6), titleLabel.trailingAnchor.constraint(equalTo: infoCard.trailingAnchor, constant: -8), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 5), subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), subtitleLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor) ]) } private func setupIdleView() { let scrollView = makeStateScrollView(in: idleView) let contentView = UIView() scrollView.addSubview(contentView) pinContentView(contentView, to: scrollView) contentView.addSubview(idlePulseView) idlePulseView.translatesAutoresizingMaskIntoConstraints = false setupContactPanel() contentView.addSubview(contactPanel) contactPanel.translatesAutoresizingMaskIntoConstraints = false let addContactCard = makeAddContactCard() contentView.addSubview(addContactCard) addContactCard.translatesAutoresizingMaskIntoConstraints = false let pulseWidth = idlePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) pulseWidth.priority = .defaultHigh NSLayoutConstraint.activate([ idlePulseView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15), idlePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), pulseWidth, idlePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), idlePulseView.heightAnchor.constraint(equalTo: idlePulseView.widthAnchor), contactPanel.topAnchor.constraint(equalTo: idlePulseView.bottomAnchor, constant: 18), contactPanel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), contactPanel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), contactPanel.heightAnchor.constraint(equalToConstant: 104), addContactCard.topAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: 17), addContactCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15), addContactCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -15), addContactCard.heightAnchor.constraint(equalToConstant: 88), addContactCard.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20) ]) } private func setupContactPanel() { contactPanel.backgroundColor = UIColor.white.withAlphaComponent(0.95) contactPanel.layer.cornerRadius = 14 contactPanel.layer.shadowColor = UIColor.black.cgColor contactPanel.layer.shadowOpacity = 0.025 contactPanel.layer.shadowRadius = 8 contactPanel.layer.shadowOffset = CGSize(width: 0, height: 3) let locationIcon = UIImageView(image: UIImage(named: "SOS/sos_contact_location")) locationIcon.contentMode = .scaleAspectFit contactPanel.addSubview(locationIcon) locationIcon.translatesAutoresizingMaskIntoConstraints = false let titleLabel = UILabel() titleLabel.text = "紧急联系人" titleLabel.textColor = UIColor(hexStr: "#273246") titleLabel.font = .systemFont(ofSize: 14, weight: .semibold) contactPanel.addSubview(titleLabel) titleLabel.translatesAutoresizingMaskIntoConstraints = false let arrow = UIImageView(image: UIImage(named: "SOS/sos_arrow_right")) arrow.contentMode = .scaleAspectFit contactPanel.addSubview(arrow) arrow.translatesAutoresizingMaskIntoConstraints = false contactScrollView.showsHorizontalScrollIndicator = false contactScrollView.alwaysBounceHorizontal = true contactPanel.addSubview(contactScrollView) contactScrollView.translatesAutoresizingMaskIntoConstraints = false contactStack.axis = .horizontal contactStack.alignment = .fill contactStack.distribution = .fill contactStack.spacing = 10 contactScrollView.addSubview(contactStack) contactStack.translatesAutoresizingMaskIntoConstraints = false emptyContactLabel.text = "暂无紧急联系人" emptyContactLabel.textColor = UIColor(hexStr: "#AAAAAA") emptyContactLabel.font = .systemFont(ofSize: 13) contactPanel.addSubview(emptyContactLabel) emptyContactLabel.translatesAutoresizingMaskIntoConstraints = false let tap = UITapGestureRecognizer(target: self, action: #selector(handleManageContacts)) contactPanel.addGestureRecognizer(tap) NSLayoutConstraint.activate([ locationIcon.topAnchor.constraint(equalTo: contactPanel.topAnchor, constant: 13), locationIcon.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), locationIcon.widthAnchor.constraint(equalToConstant: 16), locationIcon.heightAnchor.constraint(equalToConstant: 16), titleLabel.centerYAnchor.constraint(equalTo: locationIcon.centerYAnchor), titleLabel.leadingAnchor.constraint(equalTo: locationIcon.trailingAnchor, constant: 8), arrow.centerYAnchor.constraint(equalTo: locationIcon.centerYAnchor), arrow.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12), arrow.widthAnchor.constraint(equalToConstant: 14), arrow.heightAnchor.constraint(equalToConstant: 14), contactScrollView.topAnchor.constraint(equalTo: locationIcon.bottomAnchor, constant: 8), contactScrollView.leadingAnchor.constraint(equalTo: contactPanel.leadingAnchor, constant: 12), contactScrollView.trailingAnchor.constraint(equalTo: contactPanel.trailingAnchor, constant: -12), contactScrollView.bottomAnchor.constraint(equalTo: contactPanel.bottomAnchor, constant: -9), contactStack.topAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.topAnchor), contactStack.leadingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.leadingAnchor), contactStack.trailingAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.trailingAnchor), contactStack.bottomAnchor.constraint(equalTo: contactScrollView.contentLayoutGuide.bottomAnchor), contactStack.heightAnchor.constraint(equalTo: contactScrollView.frameLayoutGuide.heightAnchor), emptyContactLabel.centerXAnchor.constraint(equalTo: contactPanel.centerXAnchor), emptyContactLabel.centerYAnchor.constraint(equalTo: contactScrollView.centerYAnchor) ]) } private func makeAddContactCard() -> UIControl { let control = UIControl() control.backgroundColor = UIColor.white.withAlphaComponent(0.95) control.layer.cornerRadius = 14 control.layer.shadowColor = UIColor.black.cgColor control.layer.shadowOpacity = 0.025 control.layer.shadowRadius = 8 control.layer.shadowOffset = CGSize(width: 0, height: 3) control.addTarget(self, action: #selector(handleManageContacts), for: .touchUpInside) let iconView = UIImageView(image: UIImage(named: "SOS/add")) iconView.contentMode = .scaleAspectFit iconView.translatesAutoresizingMaskIntoConstraints = false let label = UILabel() label.text = "添加紧急联系人" label.textColor = UIColor(hexStr: "#999999") label.font = .systemFont(ofSize: 14, weight: .regular) let contentStack = UIStackView(arrangedSubviews: [iconView, label]) contentStack.axis = .horizontal contentStack.alignment = .center contentStack.spacing = 8 control.addSubview(contentStack) contentStack.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ contentStack.centerXAnchor.constraint(equalTo: control.centerXAnchor), contentStack.centerYAnchor.constraint(equalTo: control.centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: 30), iconView.heightAnchor.constraint(equalToConstant: 30) ]) return control } private func setupCountdownView() { let scrollView = makeStateScrollView(in: countdownView) let contentView = UIView() scrollView.addSubview(contentView) pinContentView(contentView, to: scrollView, minimumHeight: true) contentView.addSubview(countdownRingView) countdownRingView.translatesAutoresizingMaskIntoConstraints = false countdownTitleLabel.attributedText = Self.cancelTitle countdownTitleLabel.textAlignment = .center countdownTitleLabel.font = .systemFont(ofSize: 22, weight: .regular) contentView.addSubview(countdownTitleLabel) countdownTitleLabel.translatesAutoresizingMaskIntoConstraints = false countdownBodyLabel.text = "10秒后,会将你的SOS和位置发送到你的圈子和紧急联系人。" countdownBodyLabel.textColor = UIColor(hexStr: "#273246") countdownBodyLabel.font = .systemFont(ofSize: 15, weight: .regular) countdownBodyLabel.textAlignment = .center countdownBodyLabel.numberOfLines = 0 contentView.addSubview(countdownBodyLabel) countdownBodyLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(countdownSlider) countdownSlider.translatesAutoresizingMaskIntoConstraints = false let ringWidth = countdownRingView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) ringWidth.priority = .defaultHigh let sliderTop = countdownSlider.topAnchor.constraint(greaterThanOrEqualTo: countdownBodyLabel.bottomAnchor, constant: 28) NSLayoutConstraint.activate([ countdownRingView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15), countdownRingView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), ringWidth, countdownRingView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), countdownRingView.heightAnchor.constraint(equalTo: countdownRingView.widthAnchor), countdownTitleLabel.topAnchor.constraint(equalTo: countdownRingView.bottomAnchor, constant: 8), countdownTitleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), countdownBodyLabel.topAnchor.constraint(equalTo: countdownTitleLabel.bottomAnchor, constant: 24), countdownBodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), countdownBodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), sliderTop, countdownSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), countdownSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), countdownSlider.heightAnchor.constraint(equalToConstant: 56), countdownSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 12)) ]) } private func setupActiveView() { let scrollView = makeStateScrollView(in: activeView) let contentView = UIView() scrollView.addSubview(contentView) pinContentView(contentView, to: scrollView, minimumHeight: true) contentView.addSubview(activePulseView) activePulseView.translatesAutoresizingMaskIntoConstraints = false let titleLabel = UILabel() titleLabel.attributedText = Self.cancelTitle titleLabel.font = .systemFont(ofSize: 22, weight: .regular) titleLabel.textAlignment = .center contentView.addSubview(titleLabel) titleLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(activeSlider) activeSlider.translatesAutoresizingMaskIntoConstraints = false let pulseWidth = activePulseView.widthAnchor.constraint(equalTo: contentView.widthAnchor, constant: -30) pulseWidth.priority = .defaultHigh NSLayoutConstraint.activate([ activePulseView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 15), activePulseView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), pulseWidth, activePulseView.widthAnchor.constraint(lessThanOrEqualToConstant: 345), activePulseView.heightAnchor.constraint(equalTo: activePulseView.widthAnchor), titleLabel.topAnchor.constraint(equalTo: activePulseView.bottomAnchor, constant: 8), titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), activeSlider.topAnchor.constraint(greaterThanOrEqualTo: titleLabel.bottomAnchor, constant: 40), activeSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 30), activeSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -30), activeSlider.heightAnchor.constraint(equalToConstant: 56), activeSlider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -(kSafeBottomMargin + 12)) ]) } private func setupActions() { idlePulseView.onTap = { [weak self] in guard let self, self.state == .idle else { return } self.setState(.countdown) } countdownRingView.onCompleted = { [weak self] in guard let self, self.state == .countdown else { return } self.setState(.sending) self.onCountdownCompleted?() } countdownSlider.onCompleted = { [weak self] in guard let self, self.state == .countdown else { return } self.countdownRingView.stop() self.setState(.idle) } activeSlider.onCompleted = { [weak self] in guard let self, self.state == .active else { return } self.activeSlider.setInteractionEnabled(false) self.onCancelActive?() } } private func makeStateScrollView(in container: UIView) -> UIScrollView { let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = false container.addSubview(scrollView) scrollView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: container.topAnchor), scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) return scrollView } private func pinContentView(_ contentView: UIView, to scrollView: UIScrollView, minimumHeight: Bool = false) { contentView.translatesAutoresizingMaskIntoConstraints = false var constraints = [ contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) ] if minimumHeight { constraints.append(contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor)) } NSLayoutConstraint.activate(constraints) } @objc private func handleManageContacts() { onManageContacts?() } private static var cancelTitle: NSAttributedString { let text = NSMutableAttributedString( string: "滑动取消", attributes: [.foregroundColor: UIColor(hexStr: "#333333")] ) text.addAttribute( .foregroundColor, value: UIColor(hexStr: "#FF383C"), range: NSRange(location: 2, length: 2) ) return text } } private final class SOSEmergencyContactCard: UIView { private let avatarView = UIImageView() private let phoneBadgeView = UIImageView(image: UIImage(named: "SOS/sos_phone_badge")) private let nameLabel = UILabel() private let phoneLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.cornerRadius = 13 layer.borderWidth = 0.5 layer.borderColor = UIColor(hexStr: "#E6E6E6").cgColor avatarView.image = UIImage(named: "Common/default_avatar") avatarView.contentMode = .scaleAspectFill avatarView.clipsToBounds = true avatarView.layer.cornerRadius = 13 addSubview(avatarView) avatarView.translatesAutoresizingMaskIntoConstraints = false phoneBadgeView.contentMode = .scaleAspectFit addSubview(phoneBadgeView) phoneBadgeView.translatesAutoresizingMaskIntoConstraints = false nameLabel.textColor = UIColor(hexStr: "#273246") nameLabel.font = .systemFont(ofSize: 12, weight: .medium) nameLabel.lineBreakMode = .byTruncatingTail addSubview(nameLabel) nameLabel.translatesAutoresizingMaskIntoConstraints = false phoneLabel.textColor = UIColor(hexStr: "#888888") phoneLabel.font = .systemFont(ofSize: 10, weight: .regular) phoneLabel.adjustsFontSizeToFitWidth = true phoneLabel.minimumScaleFactor = 0.72 addSubview(phoneLabel) phoneLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ avatarView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7), avatarView.centerYAnchor.constraint(equalTo: centerYAnchor), avatarView.widthAnchor.constraint(equalToConstant: 26), avatarView.heightAnchor.constraint(equalToConstant: 26), phoneBadgeView.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 2), phoneBadgeView.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: 2), phoneBadgeView.widthAnchor.constraint(equalToConstant: 12), phoneBadgeView.heightAnchor.constraint(equalToConstant: 12), nameLabel.topAnchor.constraint(equalTo: topAnchor, constant: 7), nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 5), nameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4), phoneLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 2), phoneLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor), phoneLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4) ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(_ contact: EmergencyContactModel) { nameLabel.text = contact.name.isEmpty ? "未命名" : contact.name phoneLabel.text = Self.maskedPhone(contact.phone) } private static func maskedPhone(_ phone: String) -> String { let digits = phone.filter { $0.isNumber } guard digits.count >= 7 else { return phone } return "\(digits.prefix(3))****\(digits.suffix(4))" } } private final class SOSActiveBackgroundView: UIView { private let gradientLayer = CAGradientLayer() private static let flashAnimationKey = "sos.active.background.flash" override init(frame: CGRect) { super.init(frame: frame) gradientLayer.type = .radial gradientLayer.colors = [ UIColor.white.cgColor, UIColor(hexStr: "#FFF5F5").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.addSublayer(gradientLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds } func startFlashing() { guard gradientLayer.animation(forKey: Self.flashAnimationKey) == nil else { return } let animation = CAKeyframeAnimation(keyPath: "opacity") animation.values = [0.42, 1.0, 0.42] animation.keyTimes = [0, 0.5, 1] animation.duration = 1.15 animation.repeatCount = .infinity animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) animation.isRemovedOnCompletion = false gradientLayer.add(animation, forKey: Self.flashAnimationKey) } func stopFlashing() { gradientLayer.removeAnimation(forKey: Self.flashAnimationKey) gradientLayer.opacity = 1 } }