// // PigeonMessageView.swift // QuickLocation // import UIKit enum PigeonMessageMode { case image case voice } final class PigeonMessageView: UIView, UITextFieldDelegate { private static let designWidth: CGFloat = 375 var onModeChanged: ((PigeonMessageMode) -> Void)? var mode: PigeonMessageMode = .image { didSet { updateMode() onModeChanged?(mode) } } lazy var navView: BaseNavigationView = { BaseNavigationView(title: "") }() let historyButton = UIButton(type: .custom) let changeRecipientButton = 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 let recipientAvatarView = UIImageView() private let recipientNameLabel = UILabel() private let recipientIdLabel = UILabel() private let recipientOnlineDotView = UIView() private let recipientOnlineLabel = UILabel() private let recipientStatusDividerView = UIView() private let recipientLocationIconView = UIImageView() private let recipientDistancePrefixLabel = UILabel() private let recipientDistanceLabel = UILabel() 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 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 isRecording = false var captionText: String { captionField.text ?? "" } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = UIColor(hexStr: "#F9F9F9") setupUI() configureRecipient(nil, distanceText: "与您相距 --") 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 configureRecipient(_ member: GroupMemberModel?, distanceText: String) { recipientAvatarView.image = member?.userIcon ?? UIImage(named: "Common/default_avatar") recipientNameLabel.text = member?.nick_name ?? "暂无圈子成员" recipientIdLabel.text = member.map { "ID:\($0.user_id)" } ?? "ID:--" let isOnline = member?.is_online == true recipientOnlineLabel.text = isOnline ? "在线" : "离线" recipientOnlineDotView.backgroundColor = isOnline ? UIColor(hexStr: "#54D34D") : UIColor(hexStr: "#C7CBD1") recipientDistanceLabel.text = distanceText .replacingOccurrences(of: "与您相距 ", with: "") changeRecipientButton.isEnabled = member != nil changeRecipientButton.alpha = member == nil ? 0.55 : 1 } func setSelectedImage(_ image: UIImage?) { selectedImageView.image = image selectedImageView.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) } 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 value = min(60, max(0, Int(seconds.rounded(.down)))) let currentTime = String(format: "%02d″", value) let timeText = "\(currentTime)/60″" 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)) 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(scaled(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) { recipientAvatarView.contentMode = .scaleAspectFill recipientAvatarView.clipsToBounds = true recipientAvatarView.layer.cornerRadius = scaled(29) card.addSubview(recipientAvatarView) recipientAvatarView.layoutChain .left(scaled(18)) .centerY() .width(scaled(58)) .height(scaled(58)) recipientNameLabel.font = .systemFont(ofSize: scaled(17), weight: .bold) recipientNameLabel.textColor = UIColor(hexStr: "#293445") recipientNameLabel.lineBreakMode = .byTruncatingTail card.addSubview(recipientNameLabel) recipientNameLabel.layoutChain .top(scaled(18)) .leftToRightOfView(recipientAvatarView, offset: scaled(16)) recipientIdLabel.font = .systemFont(ofSize: scaled(12)) recipientIdLabel.textColor = UIColor(hexStr: "#767676") recipientIdLabel.lineBreakMode = .byTruncatingMiddle card.addSubview(recipientIdLabel) recipientIdLabel.layoutChain .topToBottomOfView(recipientNameLabel, offset: scaled(4)) .leftToView(recipientNameLabel) recipientOnlineDotView.layer.cornerRadius = scaled(3) card.addSubview(recipientOnlineDotView) recipientOnlineDotView.layoutChain .topToBottomOfView(recipientIdLabel, offset: scaled(10)) .leftToView(recipientNameLabel) .width(scaled(6)) .height(scaled(6)) recipientOnlineLabel.font = .systemFont(ofSize: scaled(12), weight: .medium) recipientOnlineLabel.textColor = UIColor(hexStr: "#747A84") card.addSubview(recipientOnlineLabel) recipientOnlineLabel.layoutChain .centerY(recipientOnlineDotView) .leftToRightOfView(recipientOnlineDotView, offset: scaled(6)) recipientStatusDividerView.backgroundColor = UIColor(hexStr: "#D8DBDF") card.addSubview(recipientStatusDividerView) recipientStatusDividerView.layoutChain .centerY(recipientOnlineDotView) .leftToRightOfView(recipientOnlineLabel, offset: scaled(10)) .width(scaled(1)) .height(scaled(14)) recipientLocationIconView.image = UIImage( systemName: "mappin.circle", withConfiguration: UIImage.SymbolConfiguration( pointSize: scaled(13), weight: .regular ) ) recipientLocationIconView.tintColor = UIColor(hexStr: "#858A92") recipientLocationIconView.contentMode = .scaleAspectFit card.addSubview(recipientLocationIconView) recipientLocationIconView.layoutChain .centerY(recipientOnlineDotView) .leftToRightOfView(recipientStatusDividerView, offset: scaled(9)) .width(scaled(14)) .height(scaled(14)) recipientDistancePrefixLabel.text = "与您相距" recipientDistancePrefixLabel.font = .systemFont(ofSize: scaled(12)) recipientDistancePrefixLabel.textColor = UIColor(hexStr: "#747A84") card.addSubview(recipientDistancePrefixLabel) recipientDistancePrefixLabel.layoutChain .centerY(recipientOnlineDotView) .leftToRightOfView(recipientLocationIconView, offset: scaled(4)) recipientDistanceLabel.font = .systemFont(ofSize: scaled(12)) recipientDistanceLabel.textColor = UIColor(hexStr: "#00ADFE") card.addSubview(recipientDistanceLabel) recipientDistanceLabel.layoutChain .centerY(recipientOnlineDotView) .leftToRightOfView(recipientDistancePrefixLabel, offset: scaled(4)) changeRecipientButton.setTitle("更换收件人", for: .normal) changeRecipientButton.setTitleColor(UIColor(hexStr: "#00ADFE"), for: .normal) changeRecipientButton.titleLabel?.font = .systemFont(ofSize: scaled(12), weight: .medium) changeRecipientButton.backgroundColor = UIColor(hexStr: "#F6F6F6") changeRecipientButton.layer.cornerRadius = scaled(15) card.addSubview(changeRecipientButton) changeRecipientButton.layoutChain .right(scaled(12)) .centerY() .width(scaled(70)) .height(scaled(30)) recipientNameLabel.layoutChain.rightToLeftOfView( changeRecipientButton, offset: -scaled(8), relation: .lessThanOrEqual ) recipientIdLabel.layoutChain.rightToLeftOfView( changeRecipientButton, offset: -scaled(8), relation: .lessThanOrEqual ) } 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 selectedImageView.contentMode = .scaleAspectFill selectedImageView.clipsToBounds = true imageEditorView.addSubview(selectedImageView) selectedImageView.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″/60″" 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 } 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 } } 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() } } } final class PigeonRecipientPickerView: UIView { private let members: [GroupMemberModel] private let selectedId: String private let onSelect: (GroupMemberModel) -> Void private let tableView = UITableView(frame: .zero, style: .plain) private let sheet = UIView() static func show( members: [GroupMemberModel], selectedId: String, onSelect: @escaping (GroupMemberModel) -> Void ) { guard let window = kKeyWindow, !members.isEmpty else { return } let picker = PigeonRecipientPickerView( frame: CGRect(origin: .zero, size: kScreenSize), members: members, selectedId: selectedId, onSelect: onSelect ) window.addSubview(picker) picker.alpha = 0 UIView.animate(withDuration: 0.2) { picker.alpha = 1 } } private init( frame: CGRect, members: [GroupMemberModel], selectedId: String, onSelect: @escaping (GroupMemberModel) -> Void ) { self.members = members self.selectedId = selectedId self.onSelect = onSelect super.init(frame: frame) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { backgroundColor = UIColor.black.withAlphaComponent(0.48) let backgroundTap = UITapGestureRecognizer(target: self, action: #selector(dismiss)) backgroundTap.delegate = self addGestureRecognizer(backgroundTap) sheet.backgroundColor = .white sheet.layer.cornerRadius = 26 sheet.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] addSubview(sheet) sheet.layoutChain.bottom().edgesHorzontal().height(min(460, CGFloat(members.count * 72 + 86))) let title = UILabel() title.text = "选择收件人" title.font = .systemFont(ofSize: 20, weight: .bold) title.textColor = UIColor(hexStr: "#293445") sheet.addSubview(title) title.layoutChain.top(20).left(20) tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.rowHeight = 68 tableView.dataSource = self tableView.delegate = self tableView.register(PigeonRecipientCell.self, forCellReuseIdentifier: "PigeonRecipientCell") sheet.addSubview(tableView) tableView.layoutChain.top(58).edgesHorzontal().bottom(kSafeBottomMargin) } @objc private func dismiss() { UIView.animate(withDuration: 0.18, animations: { self.alpha = 0 }) { _ in self.removeFromSuperview() } } } extension PigeonRecipientPickerView: UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { !(touch.view?.isDescendant(of: sheet) ?? false) } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { members.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell( withIdentifier: "PigeonRecipientCell", for: indexPath ) as? PigeonRecipientCell else { return UITableViewCell() } let member = members[indexPath.row] cell.configure(member: member, selected: member.user_id == selectedId) return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { onSelect(members[indexPath.row]) dismiss() } } private final class PigeonRecipientCell: UITableViewCell { private let avatarView = UIImageView() private let nameLabel = UILabel() private let statusLabel = UILabel() private let tickView = UIImageView(image: UIImage(named: "PigeonMessage/tick_circle")) override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none avatarView.contentMode = .scaleAspectFill avatarView.clipsToBounds = true avatarView.layer.cornerRadius = 20 nameLabel.font = .systemFont(ofSize: 16, weight: .semibold) nameLabel.textColor = UIColor(hexStr: "#293445") statusLabel.font = .systemFont(ofSize: 12) statusLabel.textColor = UIColor(hexStr: "#9EA3AA") contentView.addSubview(avatarView) contentView.addSubview(nameLabel) contentView.addSubview(statusLabel) contentView.addSubview(tickView) avatarView.layoutChain.left(20).centerY().width(48).height(48) nameLabel.layoutChain.top(13).leftToRightOfView(avatarView, offset: 14) statusLabel.layoutChain.topToBottomOfView(nameLabel, offset: 4).leftToView(nameLabel) tickView.layoutChain.right(20).centerY().width(16).height(16) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(member: GroupMemberModel, selected: Bool) { avatarView.image = member.userIcon nameLabel.text = member.nick_name statusLabel.text = member.is_online ? "在线" : "离线" tickView.isHidden = !selected } }