585 lines
19 KiB
Swift
585 lines
19 KiB
Swift
//
|
||
// 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")
|
||
}
|
||
}
|