// // 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 = [] 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) { 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() } } }