771 lines
26 KiB
Swift
771 lines
26 KiB
Swift
//
|
||
// 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..<colorCount).filter { $0 != prev }
|
||
// 首尾成环时避免与第一个相同(仅当人数>2)
|
||
if i == members.count - 1, members.count > 2, let first = result.first {
|
||
options = options.filter { $0 != first }
|
||
if options.isEmpty { options = Array(0..<colorCount).filter { $0 != prev } }
|
||
}
|
||
let hash = abs(m.user_id.hashValue)
|
||
let pick = options[hash % options.count]
|
||
result.append(pick)
|
||
prev = pick
|
||
}
|
||
return result
|
||
}
|
||
|
||
// MARK: - UI
|
||
|
||
private lazy var ropeShadowLayer: CAShapeLayer = {
|
||
let layer = CAShapeLayer()
|
||
layer.strokeColor = UIColor(hexStr: "#C8D0D8").withAlphaComponent(0.55).cgColor
|
||
layer.fillColor = UIColor.clear.cgColor
|
||
layer.lineWidth = 1.5
|
||
layer.lineCap = .round
|
||
return layer
|
||
}()
|
||
|
||
private lazy var ropeLayer: CAShapeLayer = {
|
||
let layer = CAShapeLayer()
|
||
layer.strokeColor = UIColor.white.cgColor
|
||
layer.fillColor = UIColor.clear.cgColor
|
||
layer.lineWidth = 1
|
||
layer.lineCap = .round
|
||
return layer
|
||
}()
|
||
|
||
private lazy var collectionView: UICollectionView = {
|
||
let layout = MinePhotoWallFlowLayout()
|
||
layout.scrollDirection = .horizontal
|
||
layout.itemSize = itemSize
|
||
layout.minimumLineSpacing = itemSpacing
|
||
let side = (UIScreen.main.bounds.width - itemSize.width) / 2
|
||
let topInset = ropeCenterY - MinePolaroidCell.clipAttachOffset
|
||
layout.sectionInset = UIEdgeInsets(top: max(0, topInset), left: side, bottom: 0, right: side)
|
||
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||
cv.backgroundColor = .clear
|
||
cv.showsHorizontalScrollIndicator = false
|
||
cv.decelerationRate = .fast
|
||
cv.clipsToBounds = false
|
||
cv.dataSource = self
|
||
cv.delegate = self
|
||
cv.register(MinePolaroidCell.self)
|
||
return cv
|
||
}()
|
||
|
||
private lazy var titleLab: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 16, weight: .heavy)
|
||
label.textColor = UIColor(hexStr: "#353B4F")
|
||
label.textAlignment = .center
|
||
label.text = " "
|
||
return label
|
||
}()
|
||
|
||
private lazy var daysRow: UIView = {
|
||
let v = UIView()
|
||
v.isHidden = true
|
||
v.addSubview(daysPrefixLab)
|
||
v.addSubview(daysBadge)
|
||
v.addSubview(daysSuffixLab)
|
||
daysPrefixLab.layoutChain
|
||
.left()
|
||
.centerY()
|
||
daysBadge.layoutChain
|
||
.leftToRightOfView(daysPrefixLab, offset: 4)
|
||
.centerY()
|
||
daysSuffixLab.layoutChain
|
||
.leftToRightOfView(daysBadge, offset: 4)
|
||
.right()
|
||
.centerY()
|
||
.top()
|
||
.bottom()
|
||
return v
|
||
}()
|
||
|
||
private lazy var daysPrefixLab: UILabel = {
|
||
let label = UILabel()
|
||
label.text = "已经在圈子"
|
||
label.font = .systemFont(ofSize: 16, weight: .heavy)
|
||
label.textColor = UIColor(hexStr: "#293445")
|
||
return label
|
||
}()
|
||
|
||
private lazy var daysBadge: UILabel = {
|
||
let label = PaddingLabel()
|
||
label.font = .systemFont(ofSize: 12, weight: .bold)
|
||
label.textColor = UIColor(hexStr: "#16B3FF")
|
||
label.backgroundColor = UIColor(hexStr: "#E3F6FF")
|
||
label.cornerRadius = 4
|
||
label.clipsToBounds = true
|
||
label.textAlignment = .center
|
||
label.insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
|
||
return label
|
||
}()
|
||
|
||
private lazy var daysSuffixLab: UILabel = {
|
||
let label = UILabel()
|
||
label.text = "天"
|
||
label.font = .systemFont(ofSize: 16, weight: .heavy)
|
||
label.textColor = UIColor(hexStr: "#293445")
|
||
return label
|
||
}()
|
||
|
||
private lazy var idRow: UIView = {
|
||
let v = UIView()
|
||
v.addSubview(idLab)
|
||
v.addSubview(copyBtn)
|
||
idLab.layoutChain
|
||
.left()
|
||
.top()
|
||
.bottom()
|
||
.centerY()
|
||
copyBtn.layoutChain
|
||
.leftToRightOfView(idLab, offset: 4)
|
||
.right()
|
||
.centerY()
|
||
.width(14)
|
||
.height(14)
|
||
return v
|
||
}()
|
||
|
||
private lazy var idLab: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||
label.textColor = UIColor(hexStr: "#767676")
|
||
label.text = " "
|
||
return label
|
||
}()
|
||
|
||
private lazy var copyBtn: UIButton = {
|
||
let btn = UIButton(type: .custom)
|
||
btn.setImage(UIImage(named: "Mine/copy"), for: .normal)
|
||
return btn
|
||
}()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = .clear
|
||
layer.addSublayer(ropeShadowLayer)
|
||
layer.addSublayer(ropeLayer)
|
||
addSubview(collectionView)
|
||
addSubview(titleLab)
|
||
addSubview(daysRow)
|
||
addSubview(idRow)
|
||
|
||
collectionView.layoutChain
|
||
.top()
|
||
.edgesHorzontal()
|
||
.height(170)
|
||
|
||
titleLab.layoutChain
|
||
.topToBottomOfView(collectionView, offset: 0)
|
||
.centerX()
|
||
.height(24)
|
||
|
||
daysRow.layoutChain
|
||
.centerY(titleLab)
|
||
.centerX()
|
||
.height(25)
|
||
|
||
idRow.layoutChain
|
||
.topToBottomOfView(titleLab, offset: 6)
|
||
.centerX()
|
||
.height(16)
|
||
.bottom(4)
|
||
|
||
copyBtn.rx.tap
|
||
.subscribe(onNext: { [weak self] in
|
||
guard let self = self, self.centeredIndex < self.members.count else { return }
|
||
self.onCopyID?(self.members[self.centeredIndex].user_id)
|
||
})
|
||
.disposed(by: disposeBag)
|
||
}
|
||
|
||
private let disposeBag = DisposeBag()
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
updateRopePath()
|
||
let widthChanged = abs(bounds.width - lastLayoutWidth) > 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..<members.count {
|
||
let attrs = collectionView.layoutAttributesForItem(at: IndexPath(item: i, section: 0))
|
||
guard let mid = attrs?.center.x else { continue }
|
||
let d = abs(mid - centerX)
|
||
if d < bestDist {
|
||
bestDist = d
|
||
best = i
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
private func settleToNearest() {
|
||
guard !members.isEmpty else { return }
|
||
let idx = nearestCenterIndex()
|
||
centeredIndex = idx
|
||
let before = collectionView.contentOffset
|
||
scrollToIndex(idx, animated: true)
|
||
applyArcAndFocus()
|
||
let model = members[idx]
|
||
updateInfo(model)
|
||
onCenteredMember?(model)
|
||
// offset 未变时不会回调 didEndScrollingAnimation,直接播摆动
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
let moved = abs(self.collectionView.contentOffset.x - before.x) > 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 "--"
|
||
}
|
||
}
|