// // EmergencyContactView.swift // QuickLocation // // Created by 八条 on 2026/6/11. // import UIKit import RxSwift import RxCocoa class EmergencyContactView: UIView { var disposeBag = DisposeBag() private let footerView = EmergencyContactFooterView(frame: CGRect(x: 0, y: 0, width: kScreenWidth, height: 92)) private weak var openedCell: EmergencyContactCell? func updateCount(_ count: Int) { let prefix = "我的紧急联系人" let suffix = "(\(count)人)" let attr = NSMutableAttributedString( string: prefix, attributes: [ .font: UIFont.systemFont(ofSize: 16, weight: .semibold), .foregroundColor: UIColor(hexStr: "#0F2846") ] ) attr.append(NSAttributedString( string: suffix, attributes: [ .font: UIFont.systemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor(hexStr: "#8D97A8") ] )) titleLab.attributedText = attr } func setOpenedCell(_ cell: EmergencyContactCell?) { if let openedCell, openedCell !== cell { openedCell.closeSwipe(animated: true) } openedCell = cell } func closeOpenedCell() { openedCell?.closeSwipe(animated: true) openedCell = nil } private func setupRx() { let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap)) tap.cancelsTouchesInView = false tap.delegate = self addGestureRecognizer(tap) } @objc private func handleBackgroundTap() { closeOpenedCell() } private func setupUI() { addSubview(navBgView) addSubview(navView) addSubview(tipsView) addSubview(titleLab) addSubview(tableView) navBgView.layoutChain .edges(excludingEdge: .bottom) .heightToWidth(160 / 375) navView.layoutChain .edges(excludingEdge: .bottom) .height(kNaviHeight) tipsView.layoutChain .topToBottomOfView(navView, offset: 8) .edgesHorzontal(15) .height(36) titleLab.layoutChain .topToBottomOfView(tipsView, offset: 16) .left(15) .right(15) tableView.layoutChain .topToBottomOfView(titleLab, offset: 12) .edgesHorzontal() .bottom(kSafeBottomMargin) } lazy var navBgView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Common/navBar_bg_2") iv.contentMode = .scaleAspectFill return iv }() lazy var navView: BaseNavigationView = { BaseNavigationView(title: "紧急联系人") }() lazy var tipsView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#FFF1F1") view.cornerRadius = 10 let icon = UIImageView(image: UIImage(named: "EmergencyContact/info")) icon.contentMode = .scaleAspectFit view.addSubview(icon) icon.layoutChain .left(12) .centerY() .width(14) .height(14) let label = UILabel() label.text = "仅在使用SOS功能或紧急情况下,会主动通知紧急联系人" label.font = .systemFont(ofSize: 12, weight: .medium) label.textColor = UIColor(hexStr: "#FF5A5F") label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.8 view.addSubview(label) label.layoutChain .leftToRightOfView(icon, offset: 6) .right(12) .centerY() return view }() lazy var titleLab: UILabel = { let label = UILabel() label.numberOfLines = 1 return label }() lazy var tableView: UITableView = { let tableView = UITableView(frame: .zero, style: .plain) tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.rowHeight = 92 tableView.estimatedRowHeight = 92 tableView.bounces = true tableView.showsVerticalScrollIndicator = false tableView.register(EmergencyContactCell.self) tableView.tableFooterView = footerView return tableView }() override init(frame: CGRect) { super.init(frame: .zero) backgroundColor = UIColor(hexStr: "#F7F8FA") setupUI() setupRx() updateCount(0) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension EmergencyContactView: UITableViewDelegate, UIGestureRecognizerDelegate { func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { closeOpenedCell() } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { guard let view = touch.view else { return true } return !(view is UIControl) } } // MARK: - EmergencyContactCell class EmergencyContactCell: UITableViewCell { var onDelete: (() -> Void)? var onSwipeChanged: ((EmergencyContactCell, Bool) -> Void)? private let deleteSize: CGFloat = 68 private let swipeDelegate = EmergencyContactSwipeDelegate() private let cardInset: CGFloat = 15 private let cardGap: CGFloat = 10 private var panStartX: CGFloat = 0 private var isOpen = false private var cardLeading: NSLayoutConstraint? private var cardTrailing: NSLayoutConstraint? func configure(_ model: EmergencyContactModel) { nameLab.text = model.name phoneLab.text = Self.maskedPhone(model.phone) closeSwipe(animated: false) } func closeSwipe(animated: Bool) { setOpen(false, animated: animated) } override init(style: CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear contentView.clipsToBounds = true contentView.layoutMargins = .zero preservesSuperviewLayoutMargins = false contentView.preservesSuperviewLayoutMargins = false setupSubviews() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() closeSwipe(animated: false) onDelete = nil onSwipeChanged = nil } private func setupSubviews() { contentView.addSubview(deleteBtn) contentView.addSubview(cardView) cardView.addSubview(nameLab) cardView.addSubview(phoneLab) cardView.addSubview(arrowIcon) deleteBtn.layoutChain .right(cardInset) .centerY(cardView) .width(deleteSize) .height(deleteSize) cardView.layoutChain .top(0) .bottom(12) cardLeading = cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: cardInset) cardTrailing = cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -cardInset) cardLeading?.isActive = true cardTrailing?.isActive = true nameLab.layoutChain .top(18) .left(16) .rightToLeftOfView(arrowIcon, offset: -8) phoneLab.layoutChain .topToBottomOfView(nameLab, offset: 6) .leftToView(nameLab) .rightToView(nameLab) arrowIcon.layoutChain .right(16) .centerY() .width(18) .height(18) let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) swipeDelegate.cardView = cardView pan.delegate = swipeDelegate cardView.addGestureRecognizer(pan) let tap = UITapGestureRecognizer(target: self, action: #selector(handleCardTap)) cardView.addGestureRecognizer(tap) } @objc private func handleDelete() { onDelete?() } @objc private func handleCardTap() { if isOpen { setOpen(false, animated: true) onSwipeChanged?(self, false) } } @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: cardView) switch gesture.state { case .began: panStartX = currentOffset enclosingTableView?.isScrollEnabled = false onSwipeChanged?(self, true) case .changed: let next = min(0, max(-maxOffset, panStartX + translation.x)) applyOffset(next) case .ended, .cancelled, .failed: enclosingTableView?.isScrollEnabled = true let velocity = gesture.velocity(in: cardView).x let shouldOpen = velocity < -400 || currentOffset < -maxOffset * 0.45 setOpen(shouldOpen, animated: true) onSwipeChanged?(self, shouldOpen) default: break } } private var maxOffset: CGFloat { deleteSize + cardGap } private var enclosingTableView: UITableView? { var view: UIView? = superview while let current = view { if let tableView = current as? UITableView { return tableView } view = current.superview } return nil } private var currentOffset: CGFloat { (cardLeading?.constant ?? cardInset) - cardInset } private func applyOffset(_ offset: CGFloat) { cardLeading?.constant = cardInset + offset cardTrailing?.constant = -cardInset + offset isOpen = offset <= -maxOffset + 0.5 } private func setOpen(_ open: Bool, animated: Bool) { let offset: CGFloat = open ? -maxOffset : 0 applyOffset(offset) let updates = { self.contentView.layoutIfNeeded() } if animated { UIView.animate(withDuration: 0.22, delay: 0, options: [.curveEaseOut]) { updates() } } else { updates() } } private static func maskedPhone(_ phone: String) -> String { let digits = phone.filter { $0.isASCII && $0.isNumber } guard digits.count >= 7 else { return phone } return "\(digits.prefix(3)) **** \(digits.suffix(4))" } lazy var deleteBtn: UIButton = { let btn = UIButton(type: .custom) btn.backgroundColor = UIColor(hexStr: "#FF5A5F") btn.cornerRadius = 16 btn.setImage(UIImage(named: "EmergencyContact/trash"), for: .normal) btn.addTarget(self, action: #selector(handleDelete), for: .touchUpInside) return btn }() lazy var cardView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 16 view.layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.06).cgColor view.layer.shadowOffset = CGSize(width: 0, height: 2) view.layer.shadowOpacity = 1 view.layer.shadowRadius = 8 return view }() lazy var nameLab: UILabel = { let label = UILabel() label.textColor = UIColor(hexStr: "#0F2846") label.font = .systemFont(ofSize: 16, weight: .semibold) return label }() lazy var phoneLab: UILabel = { let label = UILabel() label.textColor = UIColor(hexStr: "#5C6675") label.font = .systemFont(ofSize: 14, weight: .regular) return label }() lazy var arrowIcon: UIImageView = { let view = UIImageView(image: UIImage(named: "EmergencyContact/arrow_right")) view.contentMode = .scaleAspectFit return view }() } private final class EmergencyContactSwipeDelegate: NSObject, UIGestureRecognizerDelegate { weak var cardView: UIView? func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } let velocity = pan.velocity(in: cardView ?? pan.view) return abs(velocity.x) > abs(velocity.y) } }