jsdw_ios/QuickLocation/Section/Mine/MinePhotoWallView.swift

771 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 "--"
}
}