// // MinePhotoWallView.swift // QuickLocation // import UIKit import RxSwift import RxCocoa /// 「我的」页顶部:绳线 + 成员照片墙 + 信息区 final class MinePhotoWallView: UIView { var onCenteredMember: ((GroupMemberModel) -> Void)? var onCopyID: ((String) -> Void)? var onTapSelfAvatar: (() -> Void)? private var members: [GroupMemberModel] = [] /// 与 members 对齐的夹子颜色下标(相邻不同色) private var clipColorIndices: [Int] = [] private var ownerUserId: String = "" private var centeredIndex: Int = 0 private var isDragging = false private var lastLayoutWidth: CGFloat = 0 /// cell 包络按选中相框 70×90 + 夹子留白 private let itemSize = CGSize(width: 72, height: 110) private let itemSpacing: CGFloat = 18 /// 左右边缘淡化带宽 private let edgeFadeWidth: CGFloat = 56 /// 绳两端距照片墙顶 private let ropeEndY: CGFloat = 30 /// 绳中间下垂量 private let ropeSag: CGFloat = 28 private var clipAttachInCell: CGFloat { MinePolaroidCell.clipAttachOffset } private let selectedSize = MinePolaroidCell.selectedFrameSize private let unselectedSize = MinePolaroidCell.unselectedFrameSize /// 绳参数 t∈[-1,1] private func ropeT(atX x: CGFloat) -> CGFloat { let w = max(bounds.width, 1) let t = (x - w * 0.5) / (w * 0.5) return max(-1, min(1, t)) } /// 绳在 x 处的 y:两端高、中间低 private func ropeY(atX x: CGFloat) -> CGFloat { let t = ropeT(atX: x) return ropeEndY + ropeSag * (1 - t * t) } /// 绳切线角(UIKit 正角顺时针),使照片跟随弧度倾斜 private func ropeAngle(atX x: CGFloat) -> CGFloat { let w = max(bounds.width, 1) let t = ropeT(atX: x) // y = end + sag*(1-t²), t=(x-w/2)/(w/2) → dy/dx = -4*sag*t/w return atan(-4 * ropeSag * t / w) } private var ropeCenterY: CGFloat { ropeEndY + ropeSag } // MARK: - Public func reload(members: [GroupMemberModel], ownerUserId: String, preferUserId: String) { self.members = members self.ownerUserId = ownerUserId self.clipColorIndices = Self.makeClipColorIndices(for: members) collectionView.reloadData() guard !members.isEmpty else { updateInfo(nil) return } let idx: Int if let i = members.firstIndex(where: { $0.user_id == preferUserId }) { idx = i } else if let i = members.firstIndex(where: { $0.user_id == AppContextManager.shared.userId }) { idx = i } else { idx = 0 } centeredIndex = idx collectionView.layoutIfNeeded() scrollToIndex(idx, animated: false) collectionView.layoutIfNeeded() applyArcAndFocus() // contentOffset / cell 布局下一拍再贴一次,避免首屏右侧离绳 DispatchQueue.main.async { [weak self] in self?.applyArcAndFocus() } updateInfo(members[idx]) onCenteredMember?(members[idx]) } /// 相邻夹子不同色;同成员尽量稳定 private static func makeClipColorIndices(for members: [GroupMemberModel]) -> [Int] { let colorCount = MinePolaroidCell.clipAssetNames.count guard colorCount > 1 else { return Array(repeating: 0, count: members.count) } var result: [Int] = [] var prev = -1 for (i, m) in members.enumerated() { var options = Array(0..2) if i == members.count - 1, members.count > 2, let first = result.first { options = options.filter { $0 != first } if options.isEmpty { options = Array(0.. 0.5 if widthChanged, let layout = collectionView.collectionViewLayout as? MinePhotoWallFlowLayout { lastLayoutWidth = bounds.width let side = (bounds.width - itemSize.width) / 2 let topInset = ropeCenterY - clipAttachInCell layout.sectionInset = UIEdgeInsets(top: max(0, topInset), left: side, bottom: 0, right: side) layout.invalidateLayout() collectionView.layoutIfNeeded() } applyArcAndFocus() } private func updateRopePath() { let w = bounds.width guard w > 0 else { return } let path = UIBezierPath() path.move(to: CGPoint(x: 0, y: ropeEndY)) path.addQuadCurve( to: CGPoint(x: w, y: ropeEndY), controlPoint: CGPoint(x: w * 0.5, y: ropeEndY + ropeSag * 2) ) ropeLayer.path = path.cgPath ropeLayer.frame = bounds let shadowPath = UIBezierPath() let dy: CGFloat = 1.5 shadowPath.move(to: CGPoint(x: 0, y: ropeEndY + dy)) shadowPath.addQuadCurve( to: CGPoint(x: w, y: ropeEndY + dy), controlPoint: CGPoint(x: w * 0.5, y: ropeEndY + ropeSag * 2 + dy) ) ropeShadowLayer.path = shadowPath.cgPath ropeShadowLayer.frame = bounds } private func scrollToIndex(_ index: Int, animated: Bool) { guard index >= 0, index < members.count else { return } let indexPath = IndexPath(item: index, section: 0) collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated) } private func nearestCenterIndex() -> Int { let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2 var best = 0 var bestDist = CGFloat.greatestFiniteMagnitude for i in 0.. 0.5 if !moved && !self.isDragging { self.playSwayOnce() } } } /// 衣夹贴绳 + 切线倾斜 + 选中 70×90 / 未选中 60×70 + 左右边缘淡化 private func applyArcAndFocus() { guard bounds.width > 0 else { return } let scrollCenterX = collectionView.contentOffset.x + collectionView.bounds.width / 2 let span = itemSize.width + itemSpacing for cell in collectionView.visibleCells { guard let polaroid = cell as? MinePolaroidCell, let indexPath = collectionView.indexPath(for: cell), let attrs = collectionView.layoutAttributesForItem(at: indexPath) else { continue } cell.transform = .identity let dist = abs(attrs.center.x - scrollCenterX) let t = min(dist / span, 1) // 相框真实尺寸插值(不含夹子),避免非等比 scale + 旋转剪切变形 let fw = selectedSize.width + (unselectedSize.width - selectedSize.width) * t let fh = selectedSize.height + (unselectedSize.height - selectedSize.height) * t let frameInSelf = collectionView.convert(attrs.frame, to: self) let attachX = frameInSelf.midX let attachY = frameInSelf.minY + MinePolaroidCell.clipAttachOffset let ty = ropeY(atX: attachX) - attachY + 2 let angle = ropeAngle(atX: attachX) // 左右屏边淡化到 70% 透明度,仅相框淡化,夹子保持不透明 let edgeT = min( 1, max(0, frameInSelf.midX / edgeFadeWidth), max(0, (bounds.width - frameInSelf.midX) / edgeFadeWidth) ) let frameAlpha = 0.7 + 0.3 * edgeT polaroid.setFrameAlpha(frameAlpha) polaroid.applyFocus(frameSize: CGSize(width: fw, height: fh), angle: angle) polaroid.setSelectedLook(dist < itemSize.width * 0.45) cell.transform = CGAffineTransform(translationX: 0, y: ty) } } private func updateInfo(_ model: GroupMemberModel?) { guard let model = model else { titleLab.text = " " titleLab.isHidden = false daysRow.isHidden = true idLab.text = " " return } let isSelf = model.user_id == AppContextManager.shared.userId if isSelf { titleLab.isHidden = false daysRow.isHidden = true titleLab.text = model.nick_name } else { titleLab.isHidden = true daysRow.isHidden = false daysBadge.text = model.joinDaysDisplay } idLab.text = "ID:\(model.user_id)" } private func playSwayOnce() { for cell in collectionView.visibleCells { (cell as? MinePolaroidCell)?.playSwayOnce() } } } // MARK: - Collection extension MinePhotoWallView: UICollectionViewDataSource, UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { members.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell: MinePolaroidCell = collectionView.dequeueReusableCell(for: indexPath) let model = members[indexPath.item] let colorIdx = indexPath.item < clipColorIndices.count ? clipColorIndices[indexPath.item] : 0 cell.configure( model: model, isOwner: !ownerUserId.isEmpty && ownerUserId == model.user_id, clipColorIndex: colorIdx ) return cell } func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { // 新露出的 cell 立刻贴绳倾斜 DispatchQueue.main.async { [weak self] in self?.applyArcAndFocus() } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let model = members[indexPath.item] if model.user_id == AppContextManager.shared.userId { onTapSelfAvatar?() } centeredIndex = indexPath.item let before = collectionView.contentOffset scrollToIndex(indexPath.item, animated: true) applyArcAndFocus() updateInfo(model) onCenteredMember?(model) DispatchQueue.main.async { [weak self] in guard let self = self else { return } if abs(self.collectionView.contentOffset.x - before.x) < 0.5, !self.isDragging { self.playSwayOnce() } } } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { isDragging = true for cell in collectionView.visibleCells { (cell as? MinePolaroidCell)?.stopSway() } } func scrollViewDidScroll(_ scrollView: UIScrollView) { applyArcAndFocus() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { isDragging = false settleToNearest() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { isDragging = false settleToNearest() } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { applyArcAndFocus() if !isDragging { playSwayOnce() } } } // MARK: - Flow layout(吸附居中) private final class MinePhotoWallFlowLayout: UICollectionViewFlowLayout { override func targetContentOffset( forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint ) -> CGPoint { guard let cv = collectionView else { return proposedContentOffset } let rect = CGRect(origin: proposedContentOffset, size: cv.bounds.size) guard let attrs = layoutAttributesForElements(in: rect), !attrs.isEmpty else { return proposedContentOffset } let centerX = proposedContentOffset.x + cv.bounds.width / 2 var best = attrs[0] for a in attrs where abs(a.center.x - centerX) < abs(best.center.x - centerX) { best = a } return CGPoint(x: best.center.x - cv.bounds.width / 2, y: proposedContentOffset.y) } } // MARK: - Polaroid cell final class MinePolaroidCell: UICollectionViewCell { static let selectedFrameSize = CGSize(width: 70, height: 90) static let unselectedFrameSize = CGSize(width: 60, height: 70) /// 夹子 16×24,咬合点偏上,整体略挂在绳下 static let clipSize = CGSize(width: 16, height: 24) static let clipAttachOffset: CGFloat = clipSize.height * 0.32 private static let frameTopInset: CGFloat = 12 private static let avatarInset: CGFloat = 5 static let clipAssetNames = [ "Mine/clothespin", "Mine/clothespin_blue", "Mine/clothespin_green", "Mine/clothespin_pink" ] private static let swayKey = "minePolaroidSway" private var isOwner = false private var focusFrameSize = selectedFrameSize private var focusAngle: CGFloat = 0 func configure(model: GroupMemberModel, isOwner: Bool, clipColorIndex: Int) { self.isOwner = isOwner avatarImg.image = model.userIcon ownerTag.isHidden = !isOwner let idx = ((clipColorIndex % Self.clipAssetNames.count) + Self.clipAssetNames.count) % Self.clipAssetNames.count clipImg.image = UIImage(named: Self.clipAssetNames[idx]) stopSway() } /// 按真实相框尺寸布局,整块 polaroidHost 绕衣夹只做旋转(无非等比 scale) func applyFocus(frameSize: CGSize, angle: CGFloat) { focusFrameSize = frameSize focusAngle = angle layoutPolaroidHostIfNeeded() polaroidHost.transform = CGAffineTransform(rotationAngle: angle) } func setFrameAlpha(_ alpha: CGFloat) { frameView.alpha = alpha clipImg.alpha = 1 } func setSelectedLook(_ selected: Bool) { heartImg.isHidden = !selected if selected { ownerTag.isHidden = true } else { ownerTag.isHidden = !isOwner } } func playSwayOnce() { stopSway() let anim = CAKeyframeAnimation(keyPath: "transform.rotation.z") anim.values = [0, 0.1, 0, -0.1, 0] anim.keyTimes = [0, 0.25, 0.5, 0.75, 1] anim.duration = 1.6 anim.repeatCount = 1 anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) anim.isRemovedOnCompletion = true anim.isAdditive = true swayHost.layer.add(anim, forKey: Self.swayKey) } func stopSway() { swayHost.layer.removeAnimation(forKey: Self.swayKey) } override func prepareForReuse() { super.prepareForReuse() stopSway() heartImg.isHidden = true ownerTag.isHidden = true isOwner = false focusFrameSize = Self.selectedFrameSize focusAngle = 0 polaroidHost.transform = .identity transform = .identity frameView.alpha = 1 clipImg.alpha = 1 } override func layoutSubviews() { super.layoutSubviews() layoutPolaroidHostIfNeeded() polaroidHost.transform = CGAffineTransform(rotationAngle: focusAngle) } /// 用 frame 布局 host,锚点钉在衣夹;相框用当前 focusFrameSize private func layoutPolaroidHostIfNeeded() { let fw = focusFrameSize.width let fh = focusFrameSize.height let hostW = max(fw, Self.clipSize.width) let hostH = Self.frameTopInset + fh polaroidHost.bounds = CGRect(x: 0, y: 0, width: hostW, height: hostH) let anchor = CGPoint(x: 0.5, y: Self.clipAttachOffset / max(hostH, 1)) polaroidHost.layer.anchorPoint = anchor polaroidHost.layer.position = CGPoint( x: contentView.bounds.midX, y: Self.clipAttachOffset ) swayHost.frame = polaroidHost.bounds clipImg.frame = CGRect( x: (hostW - Self.clipSize.width) / 2, y: 0, width: Self.clipSize.width, height: Self.clipSize.height ) frameView.frame = CGRect(x: (hostW - fw) / 2, y: Self.frameTopInset, width: fw, height: fh) frameView.cornerRadius = 18 * (fw / Self.selectedFrameSize.width) let avatarSide = max(0, fw - Self.avatarInset * 2) avatarImg.frame = CGRect( x: Self.avatarInset, y: Self.avatarInset, width: avatarSide, height: avatarSide ) avatarImg.cornerRadius = max(4, avatarSide * 0.2) let captionMidY = Self.avatarInset + avatarSide + (fh - Self.avatarInset - avatarSide) / 2 heartImg.bounds = CGRect(x: 0, y: 0, width: 12, height: 10) heartImg.center = CGPoint(x: fw / 2, y: captionMidY) ownerTag.sizeToFit() ownerTag.center = CGPoint(x: fw / 2, y: captionMidY) } override init(frame: CGRect) { super.init(frame: frame) contentView.clipsToBounds = false clipsToBounds = false contentView.addSubview(polaroidHost) polaroidHost.addSubview(swayHost) swayHost.addSubview(frameView) frameView.addSubview(avatarImg) frameView.addSubview(heartImg) frameView.addSubview(ownerTag) swayHost.addSubview(clipImg) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private lazy var polaroidHost: UIView = { let v = UIView() v.backgroundColor = .clear return v }() private lazy var swayHost: UIView = { let v = UIView() v.backgroundColor = .clear return v }() private lazy var frameView: UIView = { let v = UIView() v.backgroundColor = .white v.cornerRadius = 18 v.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor v.layer.shadowOffset = CGSize(width: 0, height: 2) v.layer.shadowRadius = 4 v.layer.shadowOpacity = 1 return v }() private lazy var avatarImg: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.clipsToBounds = true iv.cornerRadius = 12 iv.backgroundColor = UIColor(hexStr: "#E8EEF2") return iv }() private lazy var clipImg: UIImageView = { let iv = UIImageView(image: UIImage(named: "Mine/clothespin")) iv.contentMode = .scaleAspectFit return iv }() private lazy var heartImg: UIImageView = { let iv = UIImageView() if #available(iOS 13.0, *) { iv.image = UIImage(systemName: "heart.fill") iv.tintColor = UIColor(hexStr: "#FF6B9D") } iv.contentMode = .scaleAspectFit iv.isHidden = true return iv }() private lazy var ownerTag: UILabel = { let label = PaddingLabel() label.text = "圈主" label.font = .systemFont(ofSize: 9, weight: .semibold) label.textColor = .white label.backgroundColor = UIColor(hexStr: "#16B3FF") label.cornerRadius = 4 label.clipsToBounds = true label.textAlignment = .center label.insets = UIEdgeInsets(top: 1, left: 5, bottom: 1, right: 5) label.isHidden = true return label }() } private final class PaddingLabel: UILabel { var insets = UIEdgeInsets.zero override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { let size = super.intrinsicContentSize return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) } } extension GroupMemberModel { var joinDaysDisplay: String { if join_days > 0 { return "\(join_days)" } if join_time > 0 { let days = max(1, Int((Date().timeIntervalSince1970 - TimeInterval(join_time) / 1000) / 86400)) return "\(days)" } return "--" } }