jsdw_ios/QuickLocation/Section/PigeonMessage/PigeonMessageHistoryView.swift

480 lines
16 KiB
Swift

//
// PigeonMessageHistoryView.swift
// QuickLocation
//
import UIKit
import Kingfisher
enum PigeonHistoryKind {
case image
case voice
}
struct PigeonHistoryItem {
let id: String
let dateText: String
let timeText: String
let kind: PigeonHistoryKind
let caption: String
let image: UIImage?
let mediaURL: URL?
let senderAvatar: UIImage?
let receiverAvatars: [UIImage]
let duration: TimeInterval
}
final class PigeonMessageHistoryView: UIView {
private let navView = BaseNavigationView(title: "传书记录")
let tableView: UITableView = {
let tableView = UITableView(frame: .zero, style: .plain)
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
tableView.estimatedRowHeight = 280
tableView.rowHeight = UITableView.automaticDimension
tableView.contentInsetAdjustmentBehavior = .never
tableView.contentInset = UIEdgeInsets(top: 8, left: 0, bottom: 10, right: 0)
tableView.clipsToBounds = true
tableView.alwaysBounceVertical = true
return tableView
}()
private let sheetMaskLayer = CAShapeLayer()
private let clipOverflow: CGFloat = 12
private let sheetView: UIView = {
let view = UIView()
view.backgroundColor = .clear
view.clipsToBounds = true
return view
}()
private let sheetBackground: UIView = {
let view = UIView()
view.backgroundColor = .white
view.layer.cornerRadius = 24
view.clipsToBounds = true
return view
}()
private let footerView = UIView()
private let footerIconView = UIImageView(image: UIImage(named: "PigeonMessage/history_info"))
private let footerLabel: UILabel = {
let label = UILabel()
label.text = "飞鸽传书记录系统只保留7天哦"
label.font = .systemFont(ofSize: 12, weight: .regular)
label.textColor = UIColor(hexStr: "#9399A1")
return label
}()
private let footerStack: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 6
return stack
}()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
backgroundColor = UIColor(hexStr: "#D2F2FF")
addSubview(navView)
addSubview(sheetBackground)
addSubview(sheetView)
sheetView.addSubview(tableView)
sheetView.addSubview(footerView)
footerView.addSubview(footerStack)
footerStack.addArrangedSubview(footerIconView)
footerStack.addArrangedSubview(footerLabel)
footerIconView.contentMode = .scaleAspectFit
footerView.backgroundColor = .white
footerView.layer.cornerRadius = 24
footerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
footerView.clipsToBounds = true
navView.layoutChain
.edges(excludingEdge: .bottom)
.height(kNaviHeight)
sheetBackground.layoutChain
.topToBottomOfView(navView, offset: 10)
.left(16)
.right(16)
.bottom(max(kSafeBottomMargin, 8))
sheetView.layoutChain
.topToView(sheetBackground)
.leftToView(sheetBackground, offset: -clipOverflow)
.rightToView(sheetBackground)
.bottomToView(sheetBackground)
footerView.layoutChain
.left(clipOverflow)
.right()
.bottom()
.height(52)
footerIconView.layoutChain
.width(12)
.height(12)
footerStack.layoutChain
.top(16)
.centerX()
tableView.layoutChain
.top()
.left()
.right()
.bottomToTopOfView(footerView)
sheetView.layer.mask = sheetMaskLayer
}
override func layoutSubviews() {
super.layoutSubviews()
updateSheetMask()
}
private func updateSheetMask() {
let bounds = sheetView.bounds
guard bounds.width > 0, bounds.height > 0 else { return }
let paperRect = CGRect(
x: clipOverflow,
y: 0,
width: bounds.width - clipOverflow,
height: bounds.height
)
let path = UIBezierPath(roundedRect: paperRect, cornerRadius: 24)
path.append(UIBezierPath(
rect: CGRect(x: 0, y: 20, width: clipOverflow + 8, height: max(bounds.height - 40, 0))
))
sheetMaskLayer.frame = bounds
sheetMaskLayer.path = path.cgPath
}
}
final class PigeonHistoryCell: UITableViewCell {
static let reuseId = "PigeonHistoryCell"
var onTogglePlay: (() -> Void)?
private let clipView = UIImageView(image: UIImage(named: "PigeonMessage/history_clip"))
private let dateLabel = UILabel()
private let timeLabel = UILabel()
private let cardView = UIView()
private let avatarStackView = PigeonHistoryAvatarStackView()
private let titleLabel = UILabel()
private let mediaStack = UIStackView()
private let imageContainer = UIView()
private let photoView = UIImageView()
private let captionBackground = UIView()
private let captionLabel = UILabel()
private let voiceBar = UIView()
private let playButton = UIButton(type: .custom)
private let waveformView = ReceiveVoiceWaveformView()
private let voiceTimeLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(
item: PigeonHistoryItem,
isPlaying: Bool,
elapsed: TimeInterval
) {
dateLabel.text = item.dateText
timeLabel.text = item.timeText
avatarStackView.configure(sender: item.senderAvatar, receivers: item.receiverAvatars)
titleLabel.attributedText = Self.makeTitle(kind: item.kind)
let isVoice = item.kind == .voice
imageContainer.isHidden = isVoice
voiceBar.isHidden = !isVoice
photoView.kf.cancelDownloadTask()
if let image = item.image, !isVoice {
photoView.image = image
} else if let mediaURL = item.mediaURL, !isVoice {
photoView.kf.setImage(with: mediaURL, placeholder: nil)
} else {
photoView.image = nil
}
let caption = item.caption.trimmingCharacters(in: .whitespacesAndNewlines)
captionBackground.isHidden = caption.isEmpty
captionLabel.text = caption.isEmpty ? "" : "留言:\(String(caption.prefix(20)))"
applyPlayback(isPlaying: isPlaying, elapsed: elapsed, duration: item.duration)
}
func applyPlayback(isPlaying: Bool, elapsed: TimeInterval, duration: TimeInterval) {
playButton.isSelected = isPlaying
let safeDuration = max(duration, 0.01)
waveformView.progress = CGFloat(elapsed / safeDuration)
let current = String(format: "%02d″", Int(elapsed.rounded(.down)))
let total = String(format: "%02d″", Int(duration.rounded(.down)))
let text = "\(current)/\(total)"
let attributed = NSMutableAttributedString(
string: text,
attributes: [
.foregroundColor: UIColor(hexStr: "#86D9F8"),
.font: UIFont.systemFont(ofSize: 13, weight: .medium)
]
)
attributed.addAttribute(
.foregroundColor,
value: UIColor(hexStr: "#00ADFE"),
range: NSRange(location: 0, length: current.utf16.count)
)
voiceTimeLabel.attributedText = attributed
}
private func setupUI() {
backgroundColor = .clear
contentView.backgroundColor = .clear
selectionStyle = .none
clipsToBounds = false
contentView.clipsToBounds = false
clipView.contentMode = .scaleAspectFit
dateLabel.font = .systemFont(ofSize: 16, weight: .medium)
dateLabel.textColor = UIColor(hexStr: "#293445")
timeLabel.font = .systemFont(ofSize: 13, weight: .regular)
timeLabel.textColor = UIColor(hexStr: "#767676")
timeLabel.textAlignment = .right
cardView.backgroundColor = UIColor(hexStr: "#F5FCFF")
cardView.layer.cornerRadius = 20
cardView.clipsToBounds = true
titleLabel.textAlignment = .center
mediaStack.axis = .vertical
mediaStack.alignment = .fill
mediaStack.spacing = 0
photoView.contentMode = .scaleAspectFill
photoView.clipsToBounds = true
photoView.layer.cornerRadius = 18
photoView.backgroundColor = UIColor(hexStr: "#D9D9D9")
photoView.isUserInteractionEnabled = false
captionBackground.backgroundColor = UIColor.white.withAlphaComponent(0.78)
captionBackground.layer.cornerRadius = 12
captionBackground.clipsToBounds = true
captionLabel.font = .systemFont(ofSize: 12, weight: .medium)
captionLabel.textColor = UIColor(hexStr: "#293445")
captionLabel.numberOfLines = 2
voiceBar.backgroundColor = .white
voiceBar.layer.cornerRadius = 18
playButton.setImage(UIImage(named: "PigeonMessage/history_play"), for: .normal)
playButton.setImage(UIImage(named: "PigeonMessage/history_pause"), for: .selected)
playButton.adjustsImageWhenHighlighted = false
playButton.addTarget(self, action: #selector(tapPlay), for: .touchUpInside)
contentView.addSubview(clipView)
contentView.addSubview(dateLabel)
contentView.addSubview(timeLabel)
contentView.addSubview(cardView)
cardView.addSubview(avatarStackView)
cardView.addSubview(titleLabel)
cardView.addSubview(mediaStack)
mediaStack.addArrangedSubview(imageContainer)
mediaStack.addArrangedSubview(voiceBar)
imageContainer.addSubview(photoView)
imageContainer.addSubview(captionBackground)
captionBackground.addSubview(captionLabel)
voiceBar.addSubview(playButton)
voiceBar.addSubview(waveformView)
voiceBar.addSubview(voiceTimeLabel)
clipView.layoutChain
.top(14)
.left(2)
.width(31)
.height(16)
dateLabel.layoutChain
.centerY(clipView)
.leftToRightOfView(clipView, offset: 8)
timeLabel.layoutChain
.centerY(clipView)
.right(20)
cardView.layoutChain
.topToBottomOfView(clipView, offset: 8)
.left(27)
.right(15)
.bottom(12)
avatarStackView.layoutChain
.top(16)
.centerX()
.height(40)
titleLabel.layoutChain
.topToBottomOfView(avatarStackView, offset: 10)
.left(16)
.right(16)
mediaStack.layoutChain
.topToBottomOfView(titleLabel, offset: 12)
.left(12)
.right(12)
.bottom(14)
imageContainer.layoutChain.height(118)
photoView.layoutChain
.centerX()
.top()
.width(200)
.height(118)
captionBackground.layoutChain
.leftToView(photoView, offset: 10)
.rightToView(photoView, offset: -10)
.bottomToView(photoView, offset: -8)
captionLabel.layoutChain
.top(8)
.left(10)
.right(10)
.bottom(8)
voiceBar.layoutChain.height(52)
playButton.layoutChain
.left(12)
.centerY()
.width(26)
.height(26)
voiceTimeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
voiceTimeLabel.layoutChain
.right(12)
.centerY()
waveformView.layoutChain
.leftToRightOfView(playButton, offset: 10)
.rightToLeftOfView(voiceTimeLabel, offset: -10)
.centerY()
.height(22)
}
@objc private func tapPlay() {
onTogglePlay?()
}
private static func makeTitle(kind: PigeonHistoryKind) -> NSAttributedString {
let suffix = kind == .image ? "图片" : "语音"
let text = "你 发给TA的 \(suffix)"
let attributed = NSMutableAttributedString(
string: text,
attributes: [
.font: UIFont.systemFont(ofSize: 15, weight: .medium),
.foregroundColor: UIColor(hexStr: "#1C2430")
]
)
attributed.addAttributes(
[
.font: UIFont.systemFont(ofSize: 15, weight: .regular),
.foregroundColor: UIColor(hexStr: "#5E6874")
],
range: NSRange(location: 2, length: 5)
)
attributed.addAttributes(
[
.font: UIFont.systemFont(ofSize: 15, weight: .medium),
.foregroundColor: UIColor(hexStr: "#00ADFE")
],
range: (text as NSString).range(of: suffix)
)
return attributed
}
}
final class PigeonHistoryAvatarStackView: UIView {
private let senderView = UIImageView()
private let arrowView = UIImageView(image: UIImage(named: "PigeonMessage/history_arrow"))
private var receiverViews: [UIImageView] = []
private var widthConstraint: NSLayoutConstraint?
override init(frame: CGRect) {
super.init(frame: frame)
senderView.contentMode = .scaleAspectFill
senderView.clipsToBounds = true
senderView.layer.cornerRadius = 14
senderView.layer.borderWidth = 2
senderView.layer.borderColor = UIColor.white.cgColor
arrowView.contentMode = .scaleAspectFit
addSubview(senderView)
addSubview(arrowView)
translatesAutoresizingMaskIntoConstraints = false
widthConstraint = widthAnchor.constraint(equalToConstant: 118)
widthConstraint?.isActive = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(sender: UIImage?, receivers: [UIImage]) {
senderView.image = sender ?? UIImage(named: "UserIcon/1")
receiverViews.forEach { $0.removeFromSuperview() }
receiverViews = receivers.map { image in
let view = UIImageView(image: image)
view.contentMode = .scaleAspectFill
view.clipsToBounds = true
view.layer.cornerRadius = 14
view.layer.borderWidth = 2
view.layer.borderColor = UIColor.white.cgColor
addSubview(view)
return view
}
invalidateIntrinsicContentSize()
widthConstraint?.constant = intrinsicContentSize.width
setNeedsLayout()
}
override var intrinsicContentSize: CGSize {
let receiverCount = max(receiverViews.count, 1)
let receiversWidth = 40 + CGFloat(receiverCount - 1) * 22
return CGSize(width: 40 + 10 + 18 + 10 + receiversWidth, height: 40)
}
override func layoutSubviews() {
super.layoutSubviews()
senderView.frame = CGRect(x: 0, y: 0, width: 40, height: 40)
arrowView.frame = CGRect(x: 50, y: 14, width: 18, height: 12)
var x: CGFloat = 78
for view in receiverViews {
view.frame = CGRect(x: x, y: 0, width: 40, height: 40)
x += 22
}
for (index, view) in receiverViews.enumerated() {
view.layer.zPosition = CGFloat(receiverViews.count - index)
}
}
}