jsdw_ios/QuickLocation/Section/PigeonMessage/PigeonMessageView.swift

868 lines
32 KiB
Swift

//
// PigeonMessageView.swift
// QuickLocation
//
import UIKit
enum PigeonMessageMode {
case image
case voice
}
final class PigeonMessageView: UIView, UITextFieldDelegate {
static let maxVoiceDuration: TimeInterval = 10
private static let designWidth: CGFloat = 375
var onModeChanged: ((PigeonMessageMode) -> Void)?
var onSelectionChanged: (([GroupMemberModel]) -> Void)?
var mode: PigeonMessageMode = .image {
didSet {
updateMode()
onModeChanged?(mode)
}
}
lazy var navView: BaseNavigationView = {
BaseNavigationView(title: "")
}()
let historyButton = UIButton(type: .custom)
let switchGroupButton = UIButton(type: .custom)
let addImageButton = UIButton(type: .custom)
let deleteImageButton = UIButton(type: .custom)
let recordButton = UIButton(type: .custom)
let playPauseButton = UIButton(type: .custom)
let sendButton = UIButton(type: .custom)
private var members: [GroupMemberModel] = []
private var selectedMemberIds: Set<String> = []
private lazy var memberLayout: UICollectionViewFlowLayout = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
layout.itemSize = CGSize(width: 66, height: 84)
return layout
}()
private lazy var memberCollectionView: UICollectionView = {
let view = UICollectionView(frame: .zero, collectionViewLayout: memberLayout)
view.backgroundColor = .clear
view.showsHorizontalScrollIndicator = false
view.dataSource = self
view.delegate = self
view.register(PigeonMemberCell.self, forCellWithReuseIdentifier: PigeonMemberCell.reuseId)
return view
}()
private let imageModeButton = UIButton(type: .custom)
private let voiceModeButton = UIButton(type: .custom)
private let imageEditorView = UIView()
private let voiceEditorView = UIView()
private let selectedImageView = UIImageView()
private let selectedImageMaskView = UIView()
private let imagePlaceholderLabel = UILabel()
private let captionField = UITextField()
private let captionCountLabel = UILabel()
private let imageThumbnailView = UIImageView()
private let imageThumbnailButton = UIButton(type: .custom)
private let addTile = UIView()
private let voiceThumbnailView = UIView()
private let recordOuterHaloView = UIView()
private let recordInnerHaloView = UIView()
private let recordingHintLabel = UILabel()
private let voiceTimeLabel = UILabel()
private let waveformView = PigeonWaveformView()
private var addTileLeadingWithImage: NSLayoutConstraint?
private var addTileLeadingWithoutImage: NSLayoutConstraint?
private var selectedImageHeightConstraint: NSLayoutConstraint?
private var isRecording = false
var captionText: String {
captionField.text ?? ""
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hexStr: "#F9F9F9")
setupUI()
configureMembers([], selectedIds: [])
setSelectedImage(nil)
clearVoiceDraft()
updateMode()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var designScale: CGFloat {
min(1.104, max(0.853, bounds.width / Self.designWidth))
}
private func scaled(_ value: CGFloat) -> CGFloat {
value * designScale
}
func configureMembers(_ members: [GroupMemberModel], selectedIds: Set<String>) {
self.members = members
self.selectedMemberIds = selectedIds
memberCollectionView.reloadData()
}
func setSelectedImage(_ image: UIImage?) {
selectedImageView.image = image
selectedImageView.isHidden = image == nil
selectedImageMaskView.isHidden = image == nil
imagePlaceholderLabel.isHidden = image != nil
imageThumbnailView.image = image
imageThumbnailButton.isHidden = image == nil
// deleteImageButton.isHidden = image == nil
addTile.alpha = 1
updateAddTilePosition(hasImage: image != nil)
setNeedsLayout()
}
func setRecording(isRecording: Bool) {
self.isRecording = isRecording
recordingHintLabel.text = isRecording ? "松开发送" : "按住录音 松开发送"
recordOuterHaloView.backgroundColor = UIColor(
hexStr: isRecording ? "#DDF5FF" : "#F2FBFF"
)
recordInnerHaloView.backgroundColor = UIColor(
hexStr: isRecording ? "#CDEFFF" : "#E6F7FF"
)
recordButton.alpha = isRecording ? 0.86 : 1
playPauseButton.isEnabled = !isRecording
}
func setVoiceDuration(_ seconds: TimeInterval, hasRecording: Bool) {
let maxDuration = Self.maxVoiceDuration
let value: Int
if seconds + 0.05 >= maxDuration {
value = Int(maxDuration)
} else {
value = min(Int(maxDuration), max(0, Int(seconds.rounded(.down))))
}
let currentTime = String(format: "%02d″", value)
let timeText = "\(currentTime)/\(Int(maxDuration))"
let attributedTime = NSMutableAttributedString(
string: timeText,
attributes: [.foregroundColor: UIColor(hexStr: "#AAAAAA")]
)
attributedTime.addAttribute(
.foregroundColor,
value: UIColor(hexStr: "#13B6F1"),
range: NSRange(location: 0, length: currentTime.utf16.count)
)
voiceTimeLabel.attributedText = attributedTime
voiceThumbnailView.alpha = 1
playPauseButton.isEnabled = hasRecording
if !hasRecording, !isRecording {
waveformView.reset()
}
}
func setPlaying(_ isPlaying: Bool) {
let image: UIImage?
if isPlaying {
image = UIImage(named: "PigeonMessage/pause")
} else {
image = UIImage(
systemName: "play.circle.fill",
withConfiguration: UIImage.SymbolConfiguration(
pointSize: scaled(26),
weight: .semibold
)
)?.withTintColor(UIColor(hexStr: "#11B8F4"), renderingMode: .alwaysOriginal)
}
playPauseButton.setImage(image, for: .normal)
}
func pushMeterLevel(_ level: CGFloat) {
waveformView.push(level: level)
}
func clearImageDraft() {
captionField.text = nil
captionCountLabel.text = "0/20"
setSelectedImage(nil)
}
func clearVoiceDraft() {
setRecording(isRecording: false)
setVoiceDuration(0, hasRecording: false)
setPlaying(false)
}
private func setupUI() {
let headerView = UIImageView(image: UIImage(named: "PigeonMessage/bg"))
headerView.contentMode = .scaleToFill
headerView.clipsToBounds = true
addSubview(headerView)
headerView.layoutChain.top().edgesHorzontal().height(scaled(210))
historyButton.backgroundColor = .white.withAlphaComponent(0.8)
historyButton.setTitle(" 传书记录 ", for: .normal)
historyButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
historyButton.setTitleColor(UIColor(hexStr: "#293445"), for: .normal)
historyButton.cornerRadius = 8
addSubview(navView)
navView.addRightButton(historyButton)
navView.layoutChain.edges(excludingEdge: .bottom).height(kNaviHeight)
sendButton.setTitle("发送", for: .normal)
sendButton.setTitleColor(.white, for: .normal)
sendButton.titleLabel?.font = FontManager.boboBold(scaled(18))
sendButton.setBackgroundImage(UIImage(named: "Common/button_bg_2"), for: .normal)
sendButton.layer.cornerRadius = 20
sendButton.clipsToBounds = true
addSubview(sendButton)
sendButton.layoutChain
.bottom(kSafeBottomMargin + 10)
.edgesHorzontal(30)
.height(56)
let scrollView = UIScrollView()
scrollView.backgroundColor = UIColor(hexStr: "#F9F9F9")
scrollView.layer.cornerRadius = scaled(30)
scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
// scrollView.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
addSubview(scrollView)
scrollView.layoutChain
.top(scaled(174))
.edgesHorzontal()
.bottomToTopOfView(sendButton, offset: -scaled(12))
let contentView = UIView()
contentView.backgroundColor = UIColor(hexStr: "#F9F9F9")
scrollView.addSubview(contentView)
contentView.layoutChain.edges().widthToView(scrollView)
let sectionSparkle = UIImageView(image: UIImage(named: "PigeonMessage/sparkle"))
sectionSparkle.contentMode = .scaleAspectFit
contentView.addSubview(sectionSparkle)
sectionSparkle.layoutChain
.top(scaled(21))
.left(scaled(28))
.width(scaled(11))
.height(scaled(11))
let sectionTitle = UILabel()
sectionTitle.text = "选择收件人"
sectionTitle.font = FontManager.boboBold(scaled(18))
sectionTitle.textColor = UIColor(hexStr: "#293445")
contentView.addSubview(sectionTitle)
sectionTitle.layoutChain
.top(scaled(16))
.leftToRightOfView(sectionSparkle, offset: scaled(8))
switchGroupButton.backgroundColor = .white
switchGroupButton.setTitle(" 切换圈子 ", for: .normal)
switchGroupButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
switchGroupButton.setTitleColor(UIColor(hexStr: "#293445"), for: .normal)
switchGroupButton.layer.borderWidth = 1
switchGroupButton.layer.borderColor = UIColor(hexStr: "#E8E8E8").cgColor
switchGroupButton.cornerRadius = 14
contentView.addSubview(switchGroupButton)
switchGroupButton.layoutChain
.centerY(sectionTitle)
.right(scaled(15))
.height(28)
let recipientCard = UIView()
recipientCard.backgroundColor = .white
recipientCard.layer.cornerRadius = scaled(20)
contentView.addSubview(recipientCard)
recipientCard.layoutChain
.topToBottomOfView(sectionTitle, offset: scaled(8))
.edgesHorzontal(scaled(15))
.height(100)
setupRecipientCard(recipientCard)
let modeContainer = UIView()
modeContainer.backgroundColor = .white
modeContainer.layer.cornerRadius = scaled(22)
contentView.addSubview(modeContainer)
modeContainer.layoutChain
.topToBottomOfView(recipientCard, offset: scaled(12))
.edgesHorzontal(scaled(15))
.height(scaled(44))
setupModeButtons(modeContainer)
contentView.addSubview(imageEditorView)
contentView.addSubview(voiceEditorView)
imageEditorView.layoutChain
.topToBottomOfView(modeContainer, offset: scaled(12))
.edgesHorzontal(scaled(15))
.height(scaled(190))
voiceEditorView.layoutChain
.topToBottomOfView(modeContainer, offset: scaled(12))
.edgesHorzontal(scaled(15))
.height(scaled(190))
setupImageEditor()
setupVoiceEditor()
let thumbnailRow = UIView()
contentView.addSubview(thumbnailRow)
thumbnailRow.layoutChain
.topToBottomOfView(imageEditorView, offset: scaled(14))
.edgesHorzontal(scaled(17))
.height(scaled(60))
.bottom(scaled(20))
setupThumbnailRow(thumbnailRow)
}
private func setupRecipientCard(_ card: UIView) {
card.addSubview(memberCollectionView)
memberCollectionView.layoutChain
.edgesHorzontal(8)
.centerY()
.height(84)
}
private func setupModeButtons(_ container: UIView) {
configureModeButton(
imageModeButton,
title: "图片",
imageName: "PigeonMessage/gallery"
)
configureModeButton(
voiceModeButton,
title: "语音",
imageName: "PigeonMessage/microphone"
)
container.addSubview(imageModeButton)
container.addSubview(voiceModeButton)
imageModeButton.layoutChain
.edgesVertical(scaled(4))
.left(scaled(4))
.widthToView(container, offset: -scaled(6), multiplier: 0.5)
voiceModeButton.layoutChain
.edgesVertical(scaled(4))
.right(scaled(4))
.widthToView(container, offset: -scaled(6), multiplier: 0.5)
imageModeButton.addTarget(self, action: #selector(selectImageMode), for: .touchUpInside)
voiceModeButton.addTarget(self, action: #selector(selectVoiceMode), for: .touchUpInside)
}
private func configureModeButton(_ button: UIButton, title: String, imageName: String) {
var titleAttributes = AttributeContainer()
titleAttributes.font = FontManager.boboBold(scaled(16))
var configuration = UIButton.Configuration.plain()
configuration.attributedTitle = AttributedString(title, attributes: titleAttributes)
configuration.image = UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate)
configuration.imagePadding = scaled(6)
configuration.contentInsets = .zero
button.configuration = configuration
button.layer.cornerRadius = scaled(18)
}
private func setupImageEditor() {
imageEditorView.backgroundColor = UIColor(hexStr: "#DDE1E5")
imageEditorView.layer.cornerRadius = scaled(22)
imageEditorView.clipsToBounds = true
selectedImageMaskView.backgroundColor = .black.withAlphaComponent(0.2)
selectedImageMaskView.isHidden = true
selectedImageView.contentMode = .scaleAspectFit
selectedImageView.clipsToBounds = false
imageEditorView.addSubview(selectedImageView)
selectedImageView.layoutChain
.top()
.left()
.right()
let imageHeight = selectedImageView.heightAnchor.constraint(equalTo: imageEditorView.heightAnchor)
imageHeight.isActive = true
selectedImageHeightConstraint = imageHeight
imageEditorView.addSubview(selectedImageMaskView)
selectedImageMaskView.layoutChain.edges()
imagePlaceholderLabel.text = "点击下方添加一张图片"
imagePlaceholderLabel.font = .systemFont(ofSize: scaled(14), weight: .medium)
imagePlaceholderLabel.textColor = UIColor(hexStr: "#9298A1")
imagePlaceholderLabel.textAlignment = .center
imageEditorView.addSubview(imagePlaceholderLabel)
imagePlaceholderLabel.layoutChain.centerX().centerY()
let captionBackground = UIView()
captionBackground.backgroundColor = UIColor.white.withAlphaComponent(0.9)
captionBackground.layer.cornerRadius = scaled(12)
imageEditorView.addSubview(captionBackground)
captionBackground.layoutChain
.left(scaled(10))
.right(scaled(60))
.bottom(scaled(10))
.height(scaled(40))
captionField.attributedPlaceholder = NSAttributedString(
string: "随便说点什么吧~",
attributes: [.foregroundColor: UIColor(hexStr: "#A7ABB2")]
)
captionField.font = .systemFont(ofSize: scaled(14))
captionField.textColor = UIColor(hexStr: "#293445")
captionField.delegate = self
captionField.addTarget(self, action: #selector(captionChanged), for: .editingChanged)
captionBackground.addSubview(captionField)
captionBackground.addSubview(captionCountLabel)
captionField.layoutChain
.left(scaled(14))
.centerY()
.right(scaled(48))
.height(scaled(34))
captionCountLabel.text = "0/20"
captionCountLabel.font = .systemFont(ofSize: scaled(14))
captionCountLabel.textColor = UIColor(hexStr: "#A7ABB2")
captionCountLabel.layoutChain.right(scaled(10)).centerY()
deleteImageButton.setImage(UIImage(named: "PigeonMessage/trash"), for: .normal)
deleteImageButton.backgroundColor = UIColor.white.withAlphaComponent(0.92)
deleteImageButton.layer.cornerRadius = scaled(12)
imageEditorView.addSubview(deleteImageButton)
deleteImageButton.layoutChain
.right(scaled(10))
.bottom(scaled(10))
.width(scaled(40))
.height(scaled(40))
}
private func setupVoiceEditor() {
voiceEditorView.backgroundColor = .white
voiceEditorView.layer.cornerRadius = scaled(22)
recordOuterHaloView.backgroundColor = UIColor(hexStr: "#F2FBFF")
recordOuterHaloView.layer.cornerRadius = scaled(46)
voiceEditorView.addSubview(recordOuterHaloView)
recordOuterHaloView.layoutChain
.top(scaled(15))
.centerX()
.width(scaled(92))
.height(scaled(92))
recordInnerHaloView.backgroundColor = UIColor(hexStr: "#E6F7FF")
recordInnerHaloView.layer.cornerRadius = scaled(37)
voiceEditorView.addSubview(recordInnerHaloView)
recordInnerHaloView.layoutChain
.centerX(recordOuterHaloView)
.centerY(recordOuterHaloView)
.width(scaled(74))
.height(scaled(74))
recordButton.setImage(UIImage(named: "PigeonMessage/record_microphone"), for: .normal)
voiceEditorView.addSubview(recordButton)
recordButton.layoutChain
.centerX(recordOuterHaloView)
.centerY(recordOuterHaloView)
.width(scaled(92))
.height(scaled(92))
recordingHintLabel.text = "按住录音 松开发送"
recordingHintLabel.font = .systemFont(ofSize: scaled(14))
recordingHintLabel.textColor = UIColor(hexStr: "#A7ABB2")
voiceEditorView.addSubview(recordingHintLabel)
recordingHintLabel.layoutChain
.topToBottomOfView(recordOuterHaloView, offset: scaled(5))
.centerX()
let playbackBar = UIView()
playbackBar.backgroundColor = UIColor(hexStr: "#F5F5F6")
playbackBar.layer.cornerRadius = scaled(12)
voiceEditorView.addSubview(playbackBar)
playbackBar.layoutChain
.left(scaled(10))
.right(scaled(10))
.bottom(scaled(10))
.height(scaled(40))
setPlaying(false)
playPauseButton.isEnabled = false
playbackBar.addSubview(playPauseButton)
playPauseButton.layoutChain
.left(scaled(8))
.centerY()
.width(scaled(28))
.height(scaled(28))
playbackBar.addSubview(waveformView)
waveformView.layoutChain
.leftToRightOfView(playPauseButton, offset: scaled(8))
.centerY()
.right(scaled(66))
.height(scaled(24))
voiceTimeLabel.text = "00″/10″"
voiceTimeLabel.font = .systemFont(ofSize: scaled(16))
voiceTimeLabel.textColor = UIColor(hexStr: "#13B6F1")
playbackBar.addSubview(voiceTimeLabel)
voiceTimeLabel.layoutChain.right(scaled(10)).centerY()
}
private func setupThumbnailRow(_ row: UIView) {
imageThumbnailButton.layer.cornerRadius = scaled(20)
imageThumbnailButton.layer.borderWidth = scaled(2)
imageThumbnailButton.layer.borderColor = UIColor(hexStr: "#09B6F4").cgColor
imageThumbnailButton.clipsToBounds = true
row.addSubview(imageThumbnailButton)
imageThumbnailButton.layoutChain
.left()
.top()
.width(scaled(60))
.height(scaled(60))
imageThumbnailView.contentMode = .scaleAspectFill
imageThumbnailView.isUserInteractionEnabled = false
imageThumbnailButton.addSubview(imageThumbnailView)
imageThumbnailView.layoutChain.edges()
let tick = UIImageView(image: UIImage(named: "PigeonMessage/tick_circle"))
imageThumbnailButton.addSubview(tick)
tick.layoutChain
.right(scaled(6))
.bottom(scaled(5))
.width(scaled(16))
.height(scaled(16))
addTile.backgroundColor = UIColor(hexStr: "#EBECEE")
addTile.layer.cornerRadius = scaled(20)
row.addSubview(addTile)
addTile.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
addTile.topAnchor.constraint(equalTo: row.topAnchor),
addTile.widthAnchor.constraint(equalToConstant: scaled(60)),
addTile.heightAnchor.constraint(equalToConstant: scaled(60))
])
addTileLeadingWithImage = addTile.leadingAnchor.constraint(
equalTo: imageThumbnailButton.trailingAnchor,
constant: scaled(10)
)
addTileLeadingWithoutImage = addTile.leadingAnchor.constraint(equalTo: row.leadingAnchor)
addTileLeadingWithoutImage?.isActive = true
addTile.addSubview(addImageButton)
addImageButton.layoutChain.edges()
let addIcon = UIImageView(image: UIImage(named: "PigeonMessage/add"))
addIcon.contentMode = .scaleAspectFit
let addLabel = UILabel()
addLabel.text = "添加"
addLabel.font = .systemFont(ofSize: scaled(13))
addLabel.textColor = UIColor(hexStr: "#ADB1B8")
addTile.addSubview(addIcon)
addTile.addSubview(addLabel)
addIcon.layoutChain
.top(scaled(8))
.centerX()
.width(scaled(24))
.height(scaled(24))
addLabel.layoutChain
.topToBottomOfView(addIcon, offset: scaled(2))
.centerX()
voiceThumbnailView.backgroundColor = UIColor(hexStr: "#F2F3F4")
voiceThumbnailView.layer.cornerRadius = scaled(20)
voiceThumbnailView.layer.borderWidth = scaled(2)
voiceThumbnailView.layer.borderColor = UIColor(hexStr: "#09B6F4").cgColor
row.addSubview(voiceThumbnailView)
voiceThumbnailView.layoutChain
.left()
.top()
.width(scaled(60))
.height(scaled(60))
let mic = UIImageView(
image: UIImage(named: "PigeonMessage/microphone_thumb")?.withRenderingMode(.alwaysTemplate)
)
mic.tintColor = UIColor(hexStr: "#AEB3BA")
mic.contentMode = .scaleAspectFit
let voiceLabel = UILabel()
voiceLabel.text = "录音"
voiceLabel.font = .systemFont(ofSize: scaled(13))
voiceLabel.textColor = UIColor(hexStr: "#969CA5")
voiceThumbnailView.addSubview(mic)
voiceThumbnailView.addSubview(voiceLabel)
mic.layoutChain
.top(scaled(7))
.centerX()
.width(scaled(24))
.height(scaled(24))
voiceLabel.layoutChain
.topToBottomOfView(mic, offset: scaled(2))
.centerX()
}
@objc private func selectImageMode() {
mode = .image
}
@objc private func selectVoiceMode() {
mode = .voice
}
@objc private func captionChanged() {
let text = captionField.text ?? ""
if text.count > 20 {
captionField.text = String(text.prefix(20))
}
captionCountLabel.text = "\((captionField.text ?? "").count)/20"
}
func textField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString string: String
) -> Bool {
guard let current = textField.text,
let stringRange = Range(range, in: current) else { return true }
return current.replacingCharacters(in: stringRange, with: string).count <= 20
}
override func layoutSubviews() {
super.layoutSubviews()
updateSelectedImageTopFill()
}
private func updateSelectedImageTopFill() {
guard let oldConstraint = selectedImageHeightConstraint else { return }
let editorWidth = imageEditorView.bounds.width
let editorHeight = imageEditorView.bounds.height
guard editorWidth > 1, editorHeight > 1 else { return }
let height: CGFloat
if let image = selectedImageView.image, image.size.width > 1 {
height = max(editorHeight, editorWidth * image.size.height / image.size.width)
} else {
height = editorHeight
}
if oldConstraint.secondItem == nil, abs(oldConstraint.constant - height) < 0.5 {
return
}
oldConstraint.isActive = false
let constraint = selectedImageView.heightAnchor.constraint(equalToConstant: height)
constraint.isActive = true
selectedImageHeightConstraint = constraint
}
private func updateAddTilePosition(hasImage: Bool) {
addTileLeadingWithImage?.isActive = false
addTileLeadingWithoutImage?.isActive = false
if hasImage {
addTileLeadingWithImage?.isActive = true
} else {
addTileLeadingWithoutImage?.isActive = true
}
}
private func updateMode() {
let isImage = mode == .image
imageEditorView.isHidden = !isImage
voiceEditorView.isHidden = isImage
imageThumbnailButton.isHidden = !isImage || selectedImageView.image == nil
addTile.isHidden = !isImage
voiceThumbnailView.isHidden = isImage
styleModeButton(imageModeButton, selected: isImage)
styleModeButton(voiceModeButton, selected: !isImage)
}
private func styleModeButton(_ button: UIButton, selected: Bool) {
button.backgroundColor = selected ? UIColor(hexStr: "#45C2FF") : .clear
let foregroundColor = selected ? UIColor.white : UIColor(hexStr: "#A5A5A5")
var configuration = button.configuration
configuration?.baseForegroundColor = foregroundColor
button.configuration = configuration
button.tintColor = foregroundColor
}
}
extension PigeonMessageView: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
members.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: PigeonMemberCell.reuseId,
for: indexPath
) as! PigeonMemberCell
let member = members[indexPath.item]
cell.configure(member: member, selected: selectedMemberIds.contains(member.user_id))
cell.onToggle = { [weak self] in
self?.toggleMember(at: indexPath.item)
}
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
toggleMember(at: indexPath.item)
}
}
private extension PigeonMessageView {
func toggleMember(at index: Int) {
guard members.indices.contains(index) else { return }
let id = members[index].user_id
if selectedMemberIds.contains(id) {
selectedMemberIds.remove(id)
} else {
selectedMemberIds.insert(id)
}
memberCollectionView.reloadData()
onSelectionChanged?(members.filter { selectedMemberIds.contains($0.user_id) })
}
}
private final class PigeonMemberCell: UICollectionViewCell {
static let reuseId = "PigeonMemberCell"
var onToggle: (() -> Void)?
private let avatarView = UIImageView()
private let onlineDot = UIView()
private let nameLabel = UILabel()
private let checkBtn = UIButton(type: .custom)
override init(frame: CGRect) {
super.init(frame: frame)
avatarView.contentMode = .scaleAspectFill
avatarView.clipsToBounds = true
avatarView.layer.cornerRadius = 10
contentView.addSubview(avatarView)
avatarView.layoutChain.top(5).centerX().width(38).height(38)
onlineDot.layer.cornerRadius = 4
onlineDot.layer.borderWidth = 1.5
onlineDot.layer.borderColor = UIColor.white.cgColor
onlineDot.isUserInteractionEnabled = false
contentView.addSubview(onlineDot)
onlineDot.layoutChain.topToView(avatarView, offset: -1).rightToView(avatarView, offset: 1).width(8).height(8)
nameLabel.font = .systemFont(ofSize: 8, weight: .medium)
nameLabel.textColor = UIColor(hexStr: "#767676")
nameLabel.textAlignment = .center
nameLabel.lineBreakMode = .byTruncatingTail
nameLabel.backgroundColor = UIColor(hexStr: "#F8F8F8")
nameLabel.layer.cornerRadius = 7
nameLabel.clipsToBounds = true
contentView.addSubview(nameLabel)
nameLabel.layoutChain
.topToBottomOfView(avatarView, offset: 4)
.centerX()
.width(56)
.height(14)
checkBtn.setImage(UIImage(named: "Common/checkbox"), for: .normal)
checkBtn.setImage(UIImage(named: "Common/checkbox_on"), for: .selected)
checkBtn.imageView?.contentMode = .scaleAspectFit
checkBtn.addTarget(self, action: #selector(tapCheck), for: .touchUpInside)
contentView.addSubview(checkBtn)
checkBtn.layoutChain.topToBottomOfView(nameLabel, offset: 2).centerX().width(20).height(20)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(member: GroupMemberModel, selected: Bool) {
avatarView.image = member.userIcon.size.width > 0
? member.userIcon
: UIImage(named: "Common/default_avatar")
nameLabel.text = member.nick_name
onlineDot.backgroundColor = UIColor(hexStr: member.is_online ? "#67E151" : "#AAAAAA")
checkBtn.isSelected = selected
}
@objc private func tapCheck() {
onToggle?()
}
}
final class PigeonGradientButton: UIButton {
private let gradient = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
gradient.colors = [
UIColor(hexStr: "#00ADFE").cgColor,
UIColor(hexStr: "#45C2FF").cgColor
]
gradient.startPoint = CGPoint(x: 0, y: 0.5)
gradient.endPoint = CGPoint(x: 1, y: 0.5)
layer.insertSublayer(gradient, at: 0)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradient.frame = bounds
layer.cornerRadius = bounds.height / 2
gradient.cornerRadius = bounds.height / 2
}
}
final class PigeonWaveformView: UIView {
private static let idleLevels: [CGFloat] = [
0.12, 0.12, 0.14, 0.14, 0.16, 0.14, 0.18,
0.22, 0.18, 0.30, 0.42, 0.22, 0.34, 0.92,
0.46, 0.28, 0.22, 0.18, 0.24, 0.18, 0.16,
0.14, 0.14, 0.12, 0.12, 0.12
]
private var levels = PigeonWaveformView.idleLevels
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func push(level: CGFloat) {
levels.removeFirst()
levels.append(min(1, max(0.12, level)))
setNeedsDisplay()
}
func reset() {
levels = Self.idleLevels
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), !levels.isEmpty else { return }
let activeColor = UIColor(hexStr: "#00ADFE")
let inactiveColor = UIColor(hexStr: "#86D9F8")
let spacing = rect.width / CGFloat(levels.count)
for (index, level) in levels.enumerated() {
let edgeDistance = min(index, levels.count - 1 - index)
let color = edgeDistance < 4 ? inactiveColor : activeColor
let x = CGFloat(index) * spacing + spacing / 2
if level <= 0.18 {
let radius = min(2, spacing * 0.3)
context.setFillColor(color.cgColor)
context.fillEllipse(
in: CGRect(
x: x - radius,
y: rect.midY - radius,
width: radius * 2,
height: radius * 2
)
)
continue
}
let height = max(4, rect.height * level)
context.setStrokeColor(color.cgColor)
context.setLineWidth(min(3, spacing * 0.48))
context.setLineCap(.round)
context.move(to: CGPoint(x: x, y: rect.midY - height / 2))
context.addLine(to: CGPoint(x: x, y: rect.midY + height / 2))
context.strokePath()
}
}
}