jsdw_ios/QuickLocation/Section/Home/UnlockRequestPopView.swift

585 lines
19 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.

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