469 lines
15 KiB
Swift
469 lines
15 KiB
Swift
//
|
|
// PigeonMessageHistoryView.swift
|
|
// QuickLocation
|
|
//
|
|
|
|
import UIKit
|
|
|
|
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 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.image = item.image
|
|
captionLabel.text = "留言:\(String(item.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 = 20
|
|
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 = 20
|
|
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)
|
|
}
|
|
}
|
|
}
|