// // 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 onUploadTapped: (() -> Void)? var onTemplateSelected: ((PigeonLocalTemplate) -> Void)? var onVoiceTemplateSelected: ((PigeonVoiceTemplate?) -> 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 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 = [] private lazy var memberLayout: UICollectionViewFlowLayout = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.minimumLineSpacing = 0 layout.minimumInteritemSpacing = 0 layout.itemSize = CGSize(width: 68, height: 76) 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 recipientFadeView = PigeonRecipientFadeView() 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 overlayCaptionBar = UIView() private let overlayCaptionLabel = UILabel() private let copyFooter = UIView() private let captionField = UITextField() private let captionCountLabel = UILabel() private let templatePickerView = PigeonTemplatePickerView() private let voiceChipPicker = PigeonVoiceChipPickerView() private let playbackBar = 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 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) { self.members = members self.selectedMemberIds = selectedIds memberCollectionView.reloadData() setNeedsLayout() } func setCurrentGroupName(_ name: String) { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) var titleAttributes = AttributeContainer() titleAttributes.font = .systemFont(ofSize: scaled(12), weight: .medium) var configuration = switchGroupButton.configuration ?? UIButton.Configuration.plain() configuration.attributedTitle = AttributedString( trimmedName.isEmpty ? "当前圈子" : trimmedName, attributes: titleAttributes ) configuration.image = UIImage(named: "Home/group_name_icon") configuration.imagePadding = scaled(3) configuration.contentInsets = NSDirectionalEdgeInsets( top: 0, leading: scaled(6), bottom: 0, trailing: scaled(6) ) configuration.baseForegroundColor = UIColor(hexStr: "#293445") switchGroupButton.configuration = configuration switchGroupButton.titleLabel?.lineBreakMode = .byTruncatingTail switchGroupButton.sizeToFit() } func setSelectedImage(_ image: UIImage?, selectedTemplateID: String? = nil) { selectedImageView.image = image selectedImageView.isHidden = image == nil selectedImageMaskView.isHidden = image == nil imagePlaceholderLabel.isHidden = image != nil // deleteImageButton.isHidden = image == nil templatePickerView.setSelection( templateID: selectedTemplateID, customImageSelected: image != nil && selectedTemplateID == 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, totalDuration: TimeInterval = PigeonMessageView.maxVoiceDuration, hasRecording: Bool ) { let maxDuration = max(0.01, totalDuration) let value: Int if seconds + 0.05 >= maxDuration { value = Int(ceil(maxDuration)) } else { value = min(Int(ceil(maxDuration)), max(0, Int(seconds.rounded(.down)))) } let currentTime = String(format: "%02d″", value) let totalTime = String(format: "%02d″", Int(ceil(maxDuration))) let timeText = "\(currentTime)/\(totalTime)" 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() } updatePreviewOverlays() } 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 clearCaption() { captionField.text = nil captionCountLabel.text = "0/20" updatePreviewOverlays() } func clearImageDraft() { clearCaption() setSelectedImage(nil) } func clearVoiceDraft() { voiceChipPicker.setSelection(nil) setRecording(isRecording: false) setVoiceDuration(0, hasRecording: false) setPlaying(false) updateMode() } func setVoiceTemplate(_ template: PigeonVoiceTemplate?) { voiceChipPicker.setSelection(template) setRecording(isRecording: false) setPlaying(false) setVoiceDuration( 0, totalDuration: template?.duration ?? Self.maxVoiceDuration, hasRecording: template?.audioData != nil ) updateMode() } private func setupUI() { let headerView = PigeonHeaderGradientView() addSubview(headerView) headerView.layoutChain.top().edgesHorzontal().heightToWidth(135/375) 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 = .clear scrollView.layer.cornerRadius = scaled(30) scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] // scrollView.alwaysBounceVertical = true scrollView.showsVerticalScrollIndicator = false addSubview(scrollView) scrollView.layoutChain .topToBottomOfView(navView) .edgesHorzontal() .bottomToTopOfView(sendButton, offset: -scaled(12)) let contentView = UIView() contentView.backgroundColor = .clear 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(12) .left(27) .width(11) .height(11) let sectionTitle = UILabel() sectionTitle.text = "选择收件人" sectionTitle.font = FontManager.boboBold(scaled(18)) sectionTitle.textColor = UIColor(hexStr: "#293445") contentView.addSubview(sectionTitle) sectionTitle.layoutChain .centerY(sectionSparkle) .leftToRightOfView(sectionSparkle, offset: 8) switchGroupButton.backgroundColor = .white switchGroupButton.cornerRadius = 8 switchGroupButton.clipsToBounds = true setCurrentGroupName("") contentView.addSubview(switchGroupButton) switchGroupButton.layoutChain .centerY(sectionTitle) .right(15) .width(68, relation: .greaterThanOrEqual) .height(24) let recipientCard = UIView() recipientCard.backgroundColor = .white recipientCard.layer.cornerRadius = 20 contentView.addSubview(recipientCard) recipientCard.layoutChain .topToBottomOfView(sectionTitle, offset: 14) .edgesHorzontal(15) .height(86) setupRecipientCard(recipientCard) contentView.addSubview(imageEditorView) imageEditorView.layoutChain .topToBottomOfView(recipientCard, offset: 14) .edgesHorzontal(15) .heightToWidth(200.0 / 345.0) setupImageEditor() let thumbnailRow = UIView() contentView.addSubview(thumbnailRow) thumbnailRow.layoutChain .topToBottomOfView(imageEditorView, offset: 12) .edgesHorzontal() .height(72) setupThumbnailRow(thumbnailRow) let modeContainer = UIView() modeContainer.backgroundColor = .white modeContainer.layer.cornerRadius = 10 contentView.addSubview(modeContainer) modeContainer.layoutChain .topToBottomOfView(thumbnailRow, offset: 14) .centerX() .width(152) .height(36) setupModeButtons(modeContainer) setupCopyFooter() voiceChipPicker.onSelectRecord = { [weak self] in self?.onVoiceTemplateSelected?(nil) self?.updateMode() } voiceChipPicker.onSelectTemplate = { [weak self] template in self?.onVoiceTemplateSelected?(template) self?.updateMode() } setupVoiceEditor() let bottomStack = UIStackView(arrangedSubviews: [copyFooter, voiceChipPicker, voiceEditorView]) bottomStack.axis = .vertical bottomStack.spacing = 12 bottomStack.alignment = .fill contentView.addSubview(bottomStack) copyFooter.layoutChain.height(40) voiceChipPicker.layoutChain.height(56) voiceEditorView.layoutChain.height(110) bottomStack.layoutChain .topToBottomOfView(modeContainer, offset: 12) .edgesHorzontal(15) .bottom(16) } private func setupRecipientCard(_ card: UIView) { card.addSubview(memberCollectionView) memberCollectionView.layoutChain .edgesHorzontal(8) .centerY() .height(76) card.addSubview(recipientFadeView) recipientFadeView.isUserInteractionEnabled = false recipientFadeView.layoutChain .right() .edgesVertical() .width(30) } private func setupModeButtons(_ container: UIView) { configureModeButton( imageModeButton, title: "文案", imageName: "PigeonMessage/copy" ) configureModeButton( voiceModeButton, title: "语音", imageName: "PigeonMessage/mic_tab" ) 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 = .systemFont(ofSize: 14, weight: .bold) var configuration = UIButton.Configuration.plain() configuration.attributedTitle = AttributedString(title, attributes: titleAttributes) configuration.image = UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate) configuration.imagePadding = 4 configuration.contentInsets = .zero configuration.cornerStyle = .fixed configuration.background.cornerRadius = 10 button.configuration = configuration button.layer.cornerRadius = 10 button.clipsToBounds = true } 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 = .scaleAspectFill selectedImageView.clipsToBounds = false imageEditorView.addSubview(selectedImageView) selectedImageView.layoutChain.edges() // 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() overlayCaptionBar.backgroundColor = UIColor.white.withAlphaComponent(0.9) overlayCaptionBar.layer.cornerRadius = 12 overlayCaptionBar.isHidden = true imageEditorView.addSubview(overlayCaptionBar) overlayCaptionBar.layoutChain .left(10) .right(10) .bottom(10) .height(40) overlayCaptionLabel.font = .systemFont(ofSize: 14) overlayCaptionLabel.textColor = UIColor(hexStr: "#293445") overlayCaptionBar.addSubview(overlayCaptionLabel) overlayCaptionLabel.layoutChain.edgesHorzontal(14).centerY() playbackBar.backgroundColor = UIColor.white.withAlphaComponent(0.92) playbackBar.layer.cornerRadius = 12 playbackBar.isHidden = true imageEditorView.addSubview(playbackBar) playbackBar.layoutChain .left(10) .right(10) .bottom(10) .height(40) setPlaying(false) playPauseButton.isEnabled = false playbackBar.addSubview(playPauseButton) playPauseButton.layoutChain.left(8).centerY().width(28).height(28) playbackBar.addSubview(waveformView) voiceTimeLabel.text = "00″/10″" voiceTimeLabel.font = .systemFont(ofSize: 16) voiceTimeLabel.textColor = UIColor(hexStr: "#13B6F1") playbackBar.addSubview(voiceTimeLabel) voiceTimeLabel.layoutChain.right(10).centerY() waveformView.layoutChain .leftToRightOfView(playPauseButton, offset: 8) .centerY() .rightToLeftOfView(voiceTimeLabel, offset: -2) .height(24) } private func setupCopyFooter() { captionField.attributedPlaceholder = NSAttributedString( string: "随便说点什么吧~", attributes: [.foregroundColor: UIColor(hexStr: "#A7ABB2")] ) captionField.font = .systemFont(ofSize: 14) captionField.textColor = UIColor(hexStr: "#293445") captionField.delegate = self captionField.returnKeyType = .done captionField.addTarget(self, action: #selector(captionChanged), for: .editingChanged) let fieldBg = UIView() fieldBg.backgroundColor = .white fieldBg.layer.cornerRadius = 12 fieldBg.layer.borderWidth = 1 fieldBg.layer.borderColor = UIColor(hexStr: "#EEEEEE").cgColor copyFooter.addSubview(fieldBg) fieldBg.layoutChain.left().right(52).edgesVertical() fieldBg.addSubview(captionField) fieldBg.addSubview(captionCountLabel) captionField.layoutChain.left(14).centerY().right(48).height(34) captionCountLabel.text = "0/20" captionCountLabel.font = .systemFont(ofSize: 14) captionCountLabel.textColor = UIColor(hexStr: "#A7ABB2") captionCountLabel.layoutChain.right(10).centerY() deleteImageButton.setImage(UIImage(named: "PigeonMessage/trash"), for: .normal) deleteImageButton.backgroundColor = .white deleteImageButton.layer.cornerRadius = 12 copyFooter.addSubview(deleteImageButton) deleteImageButton.layoutChain.right().centerY().width(40).height(40) } private func setupVoiceEditor() { voiceEditorView.backgroundColor = .clear recordButton.setImage(UIImage(named: "PigeonMessage/record_btn"), for: .normal) voiceEditorView.addSubview(recordButton) recordButton.layoutChain .top() .centerX() .width(55) .height(55) recordingHintLabel.text = "按住录音 松开发送" recordingHintLabel.font = .systemFont(ofSize: 13) recordingHintLabel.textColor = UIColor(hexStr: "#A7ABB2") voiceEditorView.addSubview(recordingHintLabel) recordingHintLabel.layoutChain .topToBottomOfView(recordButton, offset: 6) .centerX() } private func setupThumbnailRow(_ row: UIView) { row.addSubview(templatePickerView) templatePickerView.layoutChain.edgesHorzontal(15).edgesVertical() templatePickerView.onUploadTapped = { [weak self] in self?.onUploadTapped?() } templatePickerView.onTemplateSelected = { [weak self] template in self?.onTemplateSelected?(template) } } @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" updatePreviewOverlays() } 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 } func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() return true } override func layoutSubviews() { super.layoutSubviews() let contentWidth = CGFloat(members.count) * memberLayout.itemSize.width recipientFadeView.isHidden = contentWidth <= memberCollectionView.bounds.width // 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 updateMode() { let isCopy = mode == .image copyFooter.isHidden = !isCopy voiceChipPicker.isHidden = isCopy let usingVoiceTemplate = voiceChipPicker.selectedTemplate != nil voiceEditorView.isHidden = isCopy || usingVoiceTemplate styleModeButton(imageModeButton, selected: isCopy) styleModeButton(voiceModeButton, selected: !isCopy) updatePreviewOverlays() } private func updatePreviewOverlays() { let caption = captionText.trimmingCharacters(in: .whitespacesAndNewlines) overlayCaptionLabel.text = caption overlayCaptionBar.isHidden = mode != .image || caption.isEmpty let hasVoiceContent = playPauseButton.isEnabled || voiceChipPicker.selectedTemplate != nil playbackBar.isHidden = mode != .voice || !hasVoiceContent } private func styleModeButton(_ button: UIButton, selected: Bool) { let foregroundColor = selected ? UIColor.white : UIColor(hexStr: "#A5A5A5") var configuration = button.configuration ?? .plain() configuration.baseForegroundColor = foregroundColor configuration.cornerStyle = .fixed configuration.background.backgroundColor = selected ? UIColor(hexStr: "#16B3FF") : .clear configuration.background.cornerRadius = 10 button.configuration = configuration button.backgroundColor = .clear button.tintColor = foregroundColor button.layer.cornerRadius = 10 button.clipsToBounds = true } } 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)) 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" private let avatarContainer = UIView() private let avatarView = UIImageView() private let onlineDot = UIView() private let nameLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) avatarContainer.layer.cornerRadius = 12 avatarContainer.clipsToBounds = false contentView.addSubview(avatarContainer) avatarContainer.layoutChain.top().centerX().width(42).height(42) avatarView.contentMode = .scaleAspectFill avatarView.clipsToBounds = true avatarView.layer.cornerRadius = 10 avatarContainer.addSubview(avatarView) avatarView.layoutChain.edges(all: 2) onlineDot.layer.cornerRadius = 4.5 onlineDot.layer.borderWidth = 1.5 onlineDot.layer.borderColor = UIColor.white.cgColor onlineDot.isUserInteractionEnabled = false contentView.addSubview(onlineDot) onlineDot.layoutChain .topToView(avatarContainer, offset: -1) .rightToView(avatarContainer, offset: 1) .width(9) .height(9) nameLabel.font = .systemFont(ofSize: 10, weight: .medium) nameLabel.textColor = UIColor(hexStr: "#293445") nameLabel.textAlignment = .center nameLabel.lineBreakMode = .byTruncatingTail nameLabel.backgroundColor = UIColor(hexStr: "#F7F7F8") nameLabel.layer.cornerRadius = 10 nameLabel.clipsToBounds = true contentView.addSubview(nameLabel) nameLabel.layoutChain .topToBottomOfView(avatarContainer, offset: 5) .centerX() .width(58) .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") avatarContainer.layer.borderWidth = selected ? 2 : 0 avatarContainer.layer.borderColor = UIColor(hexStr: "#00ADFE").cgColor } } private final class PigeonRecipientFadeView: UIView { private let gradientLayer = CAGradientLayer() override init(frame: CGRect) { super.init(frame: frame) gradientLayer.colors = [ UIColor.white.withAlphaComponent(0).cgColor, UIColor.white.cgColor ] gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) layer.addSublayer(gradientLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds } } private final class PigeonHeaderGradientView: UIView { private let gradientLayer = CAGradientLayer() override init(frame: CGRect) { super.init(frame: frame) gradientLayer.colors = [ UIColor(hexStr: "#BAF4FF").cgColor, UIColor(hexStr: "#BAF4FF").withAlphaComponent(0).cgColor ] gradientLayer.locations = [0, 1] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) layer.addSublayer(gradientLayer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds } } private final class PigeonVoiceChipPickerView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { var onSelectRecord: (() -> Void)? var onSelectTemplate: ((PigeonVoiceTemplate) -> Void)? private(set) var selectedTemplate: PigeonVoiceTemplate? private let templates = PigeonVoiceTemplate.templates private lazy var collectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.minimumLineSpacing = 8 layout.itemSize = CGSize(width: 92, height: 52) let view = UICollectionView(frame: .zero, collectionViewLayout: layout) view.backgroundColor = .clear view.showsHorizontalScrollIndicator = false view.dataSource = self view.delegate = self view.register(PigeonVoiceChipCell.self, forCellWithReuseIdentifier: PigeonVoiceChipCell.reuseID) view.contentInset = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 15) return view }() override init(frame: CGRect) { super.init(frame: frame) addSubview(collectionView) collectionView.layoutChain.edges() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setSelection(_ template: PigeonVoiceTemplate?) { selectedTemplate = template collectionView.reloadData() guard let template, let index = templates.firstIndex(where: { $0.id == template.id }) else { collectionView.setContentOffset( CGPoint(x: -collectionView.adjustedContentInset.left, y: 0), animated: false ) return } collectionView.scrollToItem( at: IndexPath(item: index + 1, section: 0), at: .centeredHorizontally, animated: true ) } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { templates.count + 1 } func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell( withReuseIdentifier: PigeonVoiceChipCell.reuseID, for: indexPath ) as! PigeonVoiceChipCell if indexPath.item == 0 { cell.configureRecord(selected: selectedTemplate == nil) } else { let template = templates[indexPath.item - 1] cell.configure(template: template, selected: selectedTemplate?.id == template.id) } return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if indexPath.item == 0 { selectedTemplate = nil collectionView.reloadData() onSelectRecord?() return } let template = templates[indexPath.item - 1] selectedTemplate = template collectionView.reloadData() onSelectTemplate?(template) } } private final class PigeonVoiceChipCell: UICollectionViewCell { static let reuseID = "PigeonVoiceChipCell" private let iconView = UIImageView() private let titleLab = UILabel() private let subLab = UILabel() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = 14 contentView.layer.borderWidth = 1 contentView.layer.borderColor = UIColor(hexStr: "#E8E8E8").cgColor contentView.addSubview(iconView) contentView.addSubview(titleLab) contentView.addSubview(subLab) iconView.contentMode = .scaleAspectFit iconView.layoutChain.left(8).centerY().width(18).height(18) titleLab.font = .systemFont(ofSize: 13, weight: .medium) titleLab.textColor = UIColor(hexStr: "#293445") titleLab.lineBreakMode = .byTruncatingTail titleLab.layoutChain.leftToRightOfView(iconView, offset: 6).top(8).right(8) subLab.font = .systemFont(ofSize: 11) subLab.textColor = UIColor(hexStr: "#A7ABB2") subLab.layoutChain.leftToView(titleLab).topToBottomOfView(titleLab, offset: 2) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configureRecord(selected: Bool) { iconView.image = UIImage(named: "PigeonMessage/voice_chip_mic") titleLab.text = "录音" subLab.text = "说点什么吧" applySelected(selected) } func configure(template: PigeonVoiceTemplate, selected: Bool) { iconView.image = UIImage(named: "PigeonMessage/voice_chip_play") titleLab.text = template.title let duration = Int(ceil(template.duration)) subLab.text = String(format: "%02d:%02d", duration / 60, duration % 60) applySelected(selected) } private func applySelected(_ selected: Bool) { contentView.layer.borderColor = UIColor(hexStr: selected ? "#00ADFE" : "#E8E8E8").cgColor contentView.layer.borderWidth = selected ? 1.5 : 1 } } private final class PigeonTemplatePickerView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { var onUploadTapped: (() -> Void)? var onTemplateSelected: ((PigeonLocalTemplate) -> Void)? private let templates = PigeonLocalTemplate.imageTemplates private var selectedTemplateID: String? private var isCustomImageSelected = false private lazy var collectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.scrollDirection = .horizontal layout.minimumLineSpacing = 10 layout.minimumInteritemSpacing = 10 layout.itemSize = CGSize(width: 48, height: 72) let view = UICollectionView(frame: .zero, collectionViewLayout: layout) view.backgroundColor = .clear view.showsHorizontalScrollIndicator = false view.alwaysBounceHorizontal = true view.contentInset = .zero view.dataSource = self view.delegate = self view.register( PigeonTemplateCell.self, forCellWithReuseIdentifier: PigeonTemplateCell.reuseID ) return view }() override init(frame: CGRect) { super.init(frame: frame) addSubview(collectionView) collectionView.layoutChain.edges() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func setSelection(templateID: String?, customImageSelected: Bool) { selectedTemplateID = templateID isCustomImageSelected = customImageSelected collectionView.reloadData() guard let templateID, let index = templates.firstIndex(where: { $0.id == templateID }) else { return } collectionView.scrollToItem( at: IndexPath(item: index + 1, section: 0), at: .centeredHorizontally, animated: true ) } func collectionView( _ collectionView: UICollectionView, numberOfItemsInSection section: Int ) -> Int { templates.count + 1 } func collectionView( _ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath ) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell( withReuseIdentifier: PigeonTemplateCell.reuseID, for: indexPath ) as! PigeonTemplateCell if indexPath.item == 0 { cell.configureAsUpload(selected: isCustomImageSelected) } else { let template = templates[indexPath.item - 1] cell.configure( template: template, selected: selectedTemplateID == template.id ) } return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if indexPath.item == 0 { onUploadTapped?() return } let template = templates[indexPath.item - 1] selectedTemplateID = template.id isCustomImageSelected = false collectionView.reloadData() onTemplateSelected?(template) } } private final class PigeonTemplateCell: UICollectionViewCell { static let reuseID = "PigeonTemplateCell" private let imageContainer = UIView() private let imageView = UIImageView() private let addImageView = UIImageView(image: UIImage(named: "PigeonMessage/add")) private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) imageContainer.backgroundColor = UIColor(hexStr: "#EBECEE") imageContainer.layer.cornerRadius = 18 imageContainer.clipsToBounds = true contentView.addSubview(imageContainer) imageContainer.layoutChain.top().left().right().height(48) imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageContainer.addSubview(imageView) imageView.layoutChain.edges() addImageView.contentMode = .scaleAspectFit imageContainer.addSubview(addImageView) addImageView.layoutChain.centerX().centerY().width(28).height(28) titleLabel.font = .systemFont(ofSize: 12, weight: .medium) titleLabel.textColor = UIColor(hexStr: "#293445") titleLabel.textAlignment = .center titleLabel.lineBreakMode = .byTruncatingTail contentView.addSubview(titleLabel) titleLabel.layoutChain.topToBottomOfView(imageContainer, offset: 4).left().right().height(20) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configureAsUpload(selected: Bool) { imageView.image = nil imageView.isHidden = true addImageView.isHidden = false titleLabel.text = "上传图片" applySelection(selected) } func configure(template: PigeonLocalTemplate, selected: Bool) { imageView.image = template.coverImage imageView.isHidden = false addImageView.isHidden = true titleLabel.text = template.title applySelection(selected) } private func applySelection(_ selected: Bool) { imageContainer.layer.borderWidth = selected ? 2 : 0 imageContainer.layer.borderColor = UIColor(hexStr: "#00ADFE").cgColor } } 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() } } }