// // UnlockRequestPopView.swift // QuickLocation // import UIKit struct UnlockRequestDisplayItem { let member: GroupMemberModel let lockStartTime: Date } final class UnlockRequestPopView: UIView { private static let shared = UnlockRequestPopView( frame: CGRect(origin: .zero, size: kScreenSize) ) private var requests: [UnlockRequestDisplayItem] = [] private var onUnlock: ((UnlockRequestDisplayItem) -> Void)? private var onReject: ((UnlockRequestDisplayItem) -> Void)? private var timer: Timer? static func show( requests: [UnlockRequestDisplayItem], onUnlock: ((UnlockRequestDisplayItem) -> Void)? = nil, onReject: ((UnlockRequestDisplayItem) -> Void)? = nil ) { guard !requests.isEmpty, let window = kKeyWindow else { return } let popup = UnlockRequestPopView.shared popup.removeFromSuperview() popup.stopTimer() popup.requests = requests popup.onUnlock = onUnlock popup.onReject = onReject popup.collectionView.reloadData() window.addSubview(popup) window.bringSubviewToFront(popup) popup.layoutIfNeeded() popup.collectionView.setContentOffset(.zero, animated: false) popup.startTimer() popup.overlayView.alpha = 0 popup.collectionView.alpha = 0 popup.collectionView.transform = CGAffineTransform(scaleX: 0.92, y: 0.92) UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut]) { popup.overlayView.alpha = 1 popup.collectionView.alpha = 1 popup.collectionView.transform = .identity } } static func dismiss() { let popup = UnlockRequestPopView.shared guard popup.superview != nil else { return } popup.stopTimer() UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) { popup.overlayView.alpha = 0 popup.collectionView.alpha = 0 popup.collectionView.transform = CGAffineTransform(scaleX: 0.94, y: 0.94) } completion: { _ in popup.removeFromSuperview() popup.collectionView.transform = .identity popup.requests.removeAll() popup.onUnlock = nil popup.onReject = nil } } private let overlayView: UIView = { let view = UIView() view.backgroundColor = UIColor.black.withAlphaComponent(0.62) return view }() private lazy var collectionView: UICollectionView = { let view = UICollectionView(frame: .zero, collectionViewLayout: UnlockRequestCardLayout()) view.backgroundColor = .clear view.showsHorizontalScrollIndicator = false view.decelerationRate = .fast view.clipsToBounds = false view.dataSource = self view.delegate = self view.register(UnlockRequestCardCell.self, forCellWithReuseIdentifier: UnlockRequestCardCell.reuseId) return view }() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear addSubview(overlayView) addSubview(collectionView) overlayView.layoutChain.edges() collectionView.layoutChain .edgesHorzontal() .centerY() .height(UnlockRequestCardLayout.itemHeight) overlayView.addGestureRecognizer( UITapGestureRecognizer(target: self, action: #selector(tapOutside)) ) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func startTimer() { refreshVisibleTimes() let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in self?.refreshVisibleTimes() } RunLoop.main.add(timer, forMode: .common) self.timer = timer } private func stopTimer() { timer?.invalidate() timer = nil } private func refreshVisibleTimes() { for case let cell as UnlockRequestCardCell in collectionView.visibleCells { cell.refreshElapsedTime() } } private func handleAction(at index: Int, isUnlock: Bool) { guard requests.indices.contains(index) else { return } let item = requests[index] if isUnlock { onUnlock?(item) } else { onReject?(item) } requests.remove(at: index) guard !requests.isEmpty else { Self.dismiss() return } collectionView.performBatchUpdates { collectionView.deleteItems(at: [IndexPath(item: index, section: 0)]) } completion: { [weak self] _ in guard let self else { return } let next = min(index, self.requests.count - 1) self.collectionView.scrollToItem( at: IndexPath(item: next, section: 0), at: .centeredHorizontally, animated: true ) } } @objc private func tapOutside() { Self.dismiss() } } extension UnlockRequestPopView: UICollectionViewDataSource, UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { requests.count } func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell( withReuseIdentifier: UnlockRequestCardCell.reuseId, for: indexPath ) as? UnlockRequestCardCell else { return UICollectionViewCell() } cell.configure(item: requests[indexPath.item]) cell.onUnlock = { [weak self, weak cell] in guard let self, let cell, let path = self.collectionView.indexPath(for: cell) else { return } self.handleAction(at: path.item, isUnlock: true) } cell.onReject = { [weak self, weak cell] in guard let self, let cell, let path = self.collectionView.indexPath(for: cell) else { return } self.handleAction(at: path.item, isUnlock: false) } return cell } func scrollViewDidScroll(_ scrollView: UIScrollView) { collectionView.collectionViewLayout.invalidateLayout() } } private final class UnlockRequestCardLayout: UICollectionViewFlowLayout { static let itemWidth: CGFloat = 335 static let itemHeight: CGFloat = 440 private let overlap: CGFloat = 35 override init() { super.init() scrollDirection = .horizontal minimumInteritemSpacing = 0 minimumLineSpacing = -overlap itemSize = CGSize(width: Self.itemWidth, height: Self.itemHeight) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepare() { super.prepare() guard let collectionView else { return } let horizontalInset = max(0, (collectionView.bounds.width - Self.itemWidth) / 2) sectionInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset) } override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { true } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let collectionView else { return super.layoutAttributesForElements(in: rect) } let pageDistance = Self.itemWidth - overlap let expandedRect = rect.insetBy(dx: -pageDistance * 3, dy: 0) guard let attributes = super.layoutAttributesForElements(in: expandedRect)? .compactMap({ $0.copy() as? UICollectionViewLayoutAttributes }) else { return super.layoutAttributesForElements(in: rect) } let visibleCenterX = collectionView.contentOffset.x + collectionView.bounds.width / 2 for attribute in attributes where attribute.representedElementCategory == .cell { applyStackEffect( to: attribute, visibleCenterX: visibleCenterX, pageDistance: pageDistance ) } return attributes } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { guard let collectionView, let attribute = super.layoutAttributesForItem(at: indexPath)? .copy() as? UICollectionViewLayoutAttributes else { return super.layoutAttributesForItem(at: indexPath) } applyStackEffect( to: attribute, visibleCenterX: collectionView.contentOffset.x + collectionView.bounds.width / 2, pageDistance: Self.itemWidth - overlap ) return attribute } private func applyStackEffect( to attribute: UICollectionViewLayoutAttributes, visibleCenterX: CGFloat, pageDistance: CGFloat ) { let progress = (attribute.center.x - visibleCenterX) / pageDistance if progress < 0 { // 已翻过的顶层卡片继续跟手向左移出。 attribute.center.x = visibleCenterX + progress * pageDistance attribute.zIndex = 3000 attribute.alpha = max(0, 1 + progress) return } guard progress <= 1.001 else { // 只露出紧邻当前卡片的一张背卡,第三张及之后完全隐藏。 attribute.alpha = 0 attribute.zIndex = 0 return } // 下一张卡片在右下方倾斜露出;滑到当前页时逐渐恢复为正面卡。 attribute.center.x = visibleCenterX + progress * 13 attribute.center.y += progress * 10 let scale = 1 - progress * 0.02 let rotation = progress * CGFloat.pi / 36 attribute.transform = CGAffineTransform(rotationAngle: rotation) .scaledBy(x: scale, y: scale) attribute.alpha = 1 - progress * 0.5 attribute.zIndex = 2000 - Int(progress * 100) } override func targetContentOffset( forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint ) -> CGPoint { guard let collectionView else { return proposedContentOffset } let targetRect = CGRect( x: proposedContentOffset.x, y: 0, width: collectionView.bounds.width, height: collectionView.bounds.height ) guard let attributes = super.layoutAttributesForElements(in: targetRect), !attributes.isEmpty else { return proposedContentOffset } let proposedCenterX = proposedContentOffset.x + collectionView.bounds.width / 2 let nearest = attributes .filter { $0.representedElementCategory == .cell } .min { abs($0.center.x - proposedCenterX) < abs($1.center.x - proposedCenterX) } guard let nearest else { return proposedContentOffset } let offsetX = nearest.center.x - collectionView.bounds.width / 2 let minX = -collectionView.adjustedContentInset.left let maxX = collectionView.contentSize.width - collectionView.bounds.width + collectionView.adjustedContentInset.right return CGPoint(x: min(max(offsetX, minX), maxX), y: proposedContentOffset.y) } } private final class UnlockRequestCardCell: UICollectionViewCell { static let reuseId = "UnlockRequestCardCell" var onUnlock: (() -> Void)? var onReject: (() -> Void)? private var item: UnlockRequestDisplayItem? private let digitLabels = (0..<4).map { _ in UnlockTimeDigitLabel() } private let cardView: UnlockRequestGradientView = { let view = UnlockRequestGradientView() view.layer.cornerRadius = 40 view.clipsToBounds = true return view }() private let avatarView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFill view.clipsToBounds = true view.layer.cornerRadius = 15 view.layer.borderColor = UIColor.white.cgColor view.layer.borderWidth = 4 return view }() private let nameContainer: UIView = { let view = UIView() view.backgroundColor = .white view.layer.cornerRadius = 10 return view }() private let nameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 14, weight: .semibold) label.textColor = UIColor(hexStr: "#293445") label.lineBreakMode = .byTruncatingTail return label }() private let lockTitleView: UIView = { let view = UIView() view.backgroundColor = .white view.layer.cornerRadius = 8 return view }() private let lockTitleLabel: UILabel = { let label = UILabel() label.text = "已锁:" label.font = .systemFont(ofSize: 13, weight: .medium) label.textColor = UIColor(hexStr: "#293445") label.textAlignment = .center return label }() private let heroView: UIImageView = { let view = UIImageView(image: UIImage(named: "Home/unlock_request_popup")) view.contentMode = .scaleAspectFit return view }() private let unlockButton: UIButton = { let button = UIButton(type: .custom) button.setTitle("解锁", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = FontManager.boboBold(18) button.backgroundColor = UIColor(hexStr: "#293445") button.layer.cornerRadius = 22 return button }() private let rejectButton: UIButton = { let button = UIButton(type: .custom) button.setTitle("残忍拒绝", for: .normal) button.setTitleColor(UIColor(hexStr: "#A8AFBA"), for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) return button }() override init(frame: CGRect) { super.init(frame: frame) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() item = nil onUnlock = nil onReject = nil } func configure(item: UnlockRequestDisplayItem) { self.item = item let image = item.member.userIcon avatarView.image = image.size == .zero ? UIImage(named: "Common/default_avatar") : image nameLabel.text = item.member.nick_name.isEmpty ? "圈子成员" : item.member.nick_name refreshElapsedTime() } func refreshElapsedTime() { guard let item else { return } let elapsed = max(0, Int(Date().timeIntervalSince(item.lockStartTime))) let capped = min(elapsed, 99 * 3600 + 59 * 60) let hours = capped / 3600 let minutes = (capped % 3600) / 60 let digits = String(format: "%02d%02d", hours, minutes) for (index, label) in digitLabels.enumerated() { let stringIndex = digits.index(digits.startIndex, offsetBy: index) label.text = String(digits[stringIndex]) } } private func setupUI() { contentView.addSubview(cardView) cardView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ cardView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), cardView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), cardView.widthAnchor.constraint(equalToConstant: 315), cardView.heightAnchor.constraint(equalToConstant: 420) ]) cardView.addSubview(avatarView) cardView.addSubview(nameContainer) nameContainer.addSubview(nameLabel) cardView.addSubview(lockTitleView) lockTitleView.addSubview(lockTitleLabel) let colonLabel = UILabel() colonLabel.text = ":" colonLabel.font = FontManager.boboBold(22) colonLabel.textColor = UIColor(hexStr: "#293445") colonLabel.textAlignment = .center let timeViews: [UIView] = [ digitLabels[0], digitLabels[1], colonLabel, digitLabels[2], digitLabels[3] ] let timeStack = UIStackView(arrangedSubviews: timeViews) timeStack.axis = .horizontal timeStack.alignment = .center timeStack.spacing = 4 cardView.addSubview(timeStack) cardView.addSubview(heroView) cardView.addSubview(unlockButton) cardView.addSubview(rejectButton) avatarView.layoutChain .top(32) .left(38) .width(60) .height(60) nameContainer.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ nameContainer.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 32), nameContainer.leftAnchor.constraint(equalTo: avatarView.rightAnchor, constant: 14), nameContainer.heightAnchor.constraint(equalToConstant: 34), nameContainer.rightAnchor.constraint(lessThanOrEqualTo: cardView.rightAnchor, constant: -18) ]) nameLabel.layoutChain.edges(all: 8) nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) lockTitleView.layoutChain .topToBottomOfView(nameContainer, offset: 7) .leftToView(nameContainer) .width(48) .height(30) lockTitleLabel.layoutChain.edges() timeStack.layoutChain .leftToRightOfView(lockTitleView, offset: 6) .centerY(lockTitleView) .height(36) for label in digitLabels { label.layoutChain.width(27).height(36) } colonLabel.layoutChain.width(8) heroView.layoutChain .top(118) .centerX() .width(180) .height(180) unlockButton.layoutChain .top(304) .edgesHorzontal(20) .height(56) rejectButton.layoutChain .topToBottomOfView(unlockButton, offset: 4) .edgesHorzontal(20) .bottom(8) unlockButton.addTarget(self, action: #selector(tapUnlock), for: .touchUpInside) rejectButton.addTarget(self, action: #selector(tapReject), for: .touchUpInside) } @objc private func tapUnlock() { onUnlock?() } @objc private func tapReject() { onReject?() } } private final class UnlockTimeDigitLabel: UILabel { override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor(hexStr: "#293445") textColor = .white font = FontManager.boboBold(20) textAlignment = .center layer.cornerRadius = 8 clipsToBounds = true text = "0" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } private final class UnlockRequestGradientView: UIView { override class var layerClass: AnyClass { CAGradientLayer.self } override init(frame: CGRect) { super.init(frame: frame) guard let layer = layer as? CAGradientLayer else { return } layer.colors = [ UIColor(hexStr: "#91DEFA").cgColor, UIColor(hexStr: "#D9F4FD").cgColor, UIColor.white.cgColor ] layer.locations = [0, 0.58, 1] layer.startPoint = CGPoint(x: 0.5, y: 0) layer.endPoint = CGPoint(x: 0.5, y: 1) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }