// // InteractionView.swift // QuickLocation // // Created by 八条 on 2026/6/15. // import UIKit import RxSwift import RxCocoa import RxDataSources import Lottie class InteractionView: UIView { var disposeBag = DisposeBag() private static let emojiCols = 4 private static let emojiRows = 3 private static let emojiPerPage = emojiCols * emojiRows func configure(member: CircleMember) { self.currentMember = member avaterImgView.image = UIImage(named: "UserIcon/\(member.avatar)") nameLab.text = member.name locationLab.text = member.address // 会员权益 if AppContextManager.shared.vip > 1 { batteryInfoView.isHidden = member.battery.int == 0 // 电量 let batteryInt = Int(member.battery) ?? 0 let batteryPercent = min(CGFloat(batteryInt), 100) batteryView.layoutChain.width(CGFloat(16 - 1) * batteryPercent / 100.0) batteryLab.text = "\(member.battery)%" // 趣味表情 lockView.isHidden = true } else { batteryInfoView.isHidden = true lockView.isHidden = false } } /// 当前表情列表(normal / fun) private let emojiRelay = BehaviorRelay<[String]>(value: UIView.emojiFileNames) private func setupRx() { // 切换表情分类 emojiNormalBtn.rx.tap .subscribe(onNext: { [weak self] _ in self?.normalBg.isHidden = true self?.interestBg.isHidden = false self?.emojiRelay.accept(UIView.emojiFileNames) }) .disposed(by: disposeBag) emojiFunBtn.rx.tap .subscribe(onNext: { [weak self] _ in self?.normalBg.isHidden = false self?.interestBg.isHidden = true self?.emojiRelay.accept(UIView.funEmojiFileNames) }) .disposed(by: disposeBag) navigateBtn.rx.tap .subscribe(onNext: { [weak self] _ in self?.onNavigate?() }) .disposed(by: disposeBag) // 绑定表情数据到 collectionView emojiRelay .map { [SectionModel(model: "", items: $0)] } .bind(to: emojiCollectionView.rx.items(dataSource: dataSource)) .disposed(by: disposeBag) // 点击表情发送 emojiCollectionView.rx.modelSelected(String.self) .subscribe(onNext: { [weak self] name in guard let self = self else { return } // 解析 index: normal_5 → 15, fun_2 → 22 let prefix: String let numStr: String if name.hasPrefix("normal_") { prefix = "1" numStr = name.replacingOccurrences(of: "normal_", with: "") } else if name.hasPrefix("fun_") { prefix = "2" numStr = name.replacingOccurrences(of: "fun_", with: "") } else { return } guard let num = Int(numStr) else { return } let indexStr = "\(prefix)\(num)" self.onSendEmote?(indexStr.integer) }) .disposed(by: disposeBag) // 切换时更新 pageControl emojiRelay .subscribe(onNext: { [weak self] items in guard let self = self else { return } let pages = (items.count + InteractionView.emojiPerPage - 1) / InteractionView.emojiPerPage self.emojiPageControl.numberOfPages = max(pages, 1) self.emojiPageControl.currentPage = 0 self.emojiCollectionView.setContentOffset(.zero, animated: false) }) .disposed(by: disposeBag) } private lazy var dataSource: RxCollectionViewSectionedReloadDataSource> = { RxCollectionViewSectionedReloadDataSource> { _, collectionView, indexPath, name in let cell: EmojiPanelCell = collectionView.dequeueReusableCell(for: indexPath) if let path = Bundle.main.path(forResource: name, ofType: "json") { cell.configure(path: path) cell.playAnimation() } return cell } }() private func setupUI() { addSubview(infoView) infoView.addSubview(headerBgView) infoView.addSubview(lineView) infoView.addSubview(avaterImgView) infoView.addSubview(batteryInfoView) batteryInfoView.addSubview(cornerView) cornerView.addSubview(batteryView) cornerView.addSubview(batteryIcon) cornerView.addSubview(batteryLab) infoView.addSubview(nameLab) infoView.addSubview(locationLab) infoView.addSubview(navigateBtn) infoView.addSubview(shareBtn) infoView.addSubview(emojiView) emojiView.addSubview(emojiBgView) emojiView.addSubview(segmentView) emojiView.addSubview(emojiCollectionView) emojiView.addSubview(emojiPageControl) infoView.layoutChain.edges() headerBgView.layoutChain .edges(excludingEdge: .bottom) .height(134) lineView.layoutChain .top(13) .width(36) .height(4) .centerX() avaterImgView.layoutChain .top(25) .left(25) .width(50) .height(50) batteryInfoView.layoutChain .leftToView(avaterImgView) .rightToView(avaterImgView) .bottomToView(avaterImgView) .height(12) cornerView.layoutChain.edges() batteryIcon.layoutChain .left(7) .centerY() .width(16) .height(8) batteryView.layoutChain .topToView(batteryIcon) .leftToView(batteryIcon, offset: -1) .bottomToView(batteryIcon) batteryLab.layoutChain .leftToRightOfView(batteryIcon, offset: 4) .right(5) .centerY() nameLab.layoutChain .topToView(avaterImgView, offset: 8) .leftToRightOfView(avaterImgView, offset: 15) locationLab.layoutChain .topToBottomOfView(nameLab) .leftToView(nameLab) shareBtn.layoutChain .right(15) .centerY(avaterImgView) .width(30) .height(30) navigateBtn.layoutChain .rightToLeftOfView(shareBtn, offset: -10) .centerY(avaterImgView) .width(30) .height(30) emojiView.layoutChain .topToBottomOfView(batteryInfoView, offset: 21) .edgesHorzontal(15) .height(276) emojiBgView.layoutChain.edges() segmentView.layoutChain .edges(excludingEdge: .bottom) .height(56) emojiPageControl.layoutChain .centerX() .height(28) .bottom(13) emojiCollectionView.layoutChain .top(51) .edgesHorzontal() .height(180) } lazy var infoView: UIView = { let view = UIView() view.backgroundColor = .white return view }() lazy var headerBgView: UIImageView = { let view = UIImageView(image: UIImage(named: "Home/interaction_header")) return view }() lazy var lineView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 2 return view }() lazy var avaterImgView: UIImageView = { let view = UIImageView() view.backgroundColor = .lightGray view.contentMode = .scaleAspectFill view.cornerRadius = 25 return view }() lazy var batteryInfoView: UIView = { let view = UIView() view.backgroundColor = .clear view.layer.shadowColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.1).cgColor view.layer.shadowOffset = CGSize(width: 0, height: 2) view.layer.shadowOpacity = 1 view.layer.shadowRadius = 6 view.isHidden = true return view }() lazy var cornerView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 6 return view }() lazy var batteryView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#75E582") return view }() lazy var batteryIcon: UIImageView = { let view = UIImageView() view.backgroundColor = .clear view.image = UIImage(named: "Home/battery") return view }() lazy var batteryLab: UILabel = { let label = UILabel() label.textColor = UIColor(hexStr: "#D4D4D4") label.font = .systemFont(ofSize: 6, weight: .medium) return label }() lazy var nameLab: UILabel = { let label = UILabel() label.textColor = UIColor(hexStr: "#0F2846") label.font = .systemFont(ofSize: 14, weight: .semibold) return label }() lazy var locationLab: UILabel = { let label = UILabel() label.textColor = UIColor(hexStr: "#8D8D8D") label.font = .systemFont(ofSize: 10, weight: .regular) return label }() lazy var navigateBtn: UIButton = { let btn = UIButton(type: .custom) btn.backgroundColor = .white btn.setImage(UIImage(named: "Home/navigate"), for: .normal) btn.cornerRadius = 15 btn.extendEdgeInsets = UIEdgeInsets(top: 30, left: 30, bottom: 30, right: 0) return btn }() lazy var shareBtn: UIButton = { let btn = UIButton(type: .custom) btn.backgroundColor = .white btn.setImage(UIImage(named: "Home/share"), for: .normal) btn.cornerRadius = 15 btn.extendEdgeInsets = UIEdgeInsets(top: 30, left: 0, bottom: 30, right: 15) return btn }() lazy var emojiView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#E3F7FE")//.clear view.cornerRadius = 15 return view }() lazy var emojiBgView: UIView = { let view = UIImageView()//UIImageView(image: UIImage(named: "Home/emoji_bg")) view.contentMode = .scaleAspectFill return view }() lazy var segmentView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#E3F7FE") view.addSubview(normalBg) view.addSubview(interestBg) normalBg.layoutChain .top().left() .height(56) .widthToHeight(220/56) interestBg.layoutChain .top().right() .height(56) .widthToHeight(220/56) let normalLab = UILabel() normalLab.text = "常规表情" normalLab.font = .systemFont(ofSize: 12, weight: .semibold) normalLab.textColor = .black normalLab.textAlignment = .center view.addSubview(normalLab) normalLab.layoutChain .top(11) .left().rightToCenterXOfView(view) let interesView = UIView() interesView.backgroundColor = .clear view.addSubview(interesView) interesView.layoutChain .top(11) .right().leftToCenterXOfView(view) .height(18) let interestLab = UILabel() interestLab.text = "趣味表情" interestLab.font = .systemFont(ofSize: 12, weight: .semibold) interestLab.textColor = .black interestLab.textAlignment = .center interesView.addSubview(interestLab) interestLab.layoutChain .top() .centerX() interesView.addSubview(lockView) lockView.layoutChain .leftToRightOfView(interestLab, offset: 4) .centerY(interestLab) .width(18) .height(18) view.addSubview(emojiNormalBtn) emojiNormalBtn.layoutChain .top() .left() .rightToCenterXOfView(view) .height(40) view.addSubview(emojiFunBtn) emojiFunBtn.layoutChain .top() .right() .leftToCenterXOfView(view) .height(40) return view }() lazy var normalBg: UIImageView = { let view = UIImageView(image: UIImage(named: "Home/emoji_normal")) view.contentMode = .scaleAspectFill view.isHidden = true return view }() lazy var interestBg: UIImageView = { let view = UIImageView(image: UIImage(named: "Home/emoji_interest")) view.contentMode = .scaleAspectFill return view }() lazy var emojiNormalBtn: UIButton = { let btn = UIButton() btn.backgroundColor = .clear return btn }() lazy var emojiFunBtn: UIButton = { let btn = UIButton() btn.backgroundColor = .clear return btn }() lazy var lockView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 9 let icon = UIImageView(image: UIImage(named: "Home/lock")) view.addSubview(icon) icon.layoutChain .centerX() .centerY() return view }() lazy var emojiCollectionView: UICollectionView = { let layout = CollectionHFlowLayout() let hSpacing: CGFloat = (kScreenWidth - 30 - CGFloat(InteractionView.emojiCols) * 50) / CGFloat(InteractionView.emojiCols + 1) let vSpacing: CGFloat = (180 - CGFloat(InteractionView.emojiRows) * 50) / CGFloat(InteractionView.emojiRows + 1) layout.rows = InteractionView.emojiRows layout.colums = InteractionView.emojiCols layout.itemSize = CGSize(width: 50, height: 50) layout.hSpacing = hSpacing layout.vSpacing = vSpacing layout.sectionInset = UIEdgeInsets(top: 0, left: hSpacing, bottom: 0, right: hSpacing) let cv = UICollectionView(frame: .zero, collectionViewLayout: layout) cv.backgroundColor = .clear cv.isPagingEnabled = true cv.bounces = false cv.showsHorizontalScrollIndicator = false cv.register(EmojiPanelCell.self) cv.delegate = self return cv }() var onNavigate: (() -> Void)? var onSendEmote: ((Int) -> Void)? var currentMember: CircleMember? lazy var emojiPageControl: UIPageControl = { let pc = UIPageControl() pc.numberOfPages = (UIView.emojiFileNames.count + InteractionView.emojiPerPage - 1) / InteractionView.emojiPerPage pc.currentPageIndicatorTintColor = UIColor(hexStr: "#16B3FF") pc.pageIndicatorTintColor = UIColor(hexStr: "#7AD6FF", alpha: 0.4) return pc }() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear // 同步预加载 fun 表情到缓存,避免首次切换卡顿 for name in UIView.funEmojiFileNames { guard EmojiPanelCell.animationCache[name] == nil, let path = Bundle.main.path(forResource: name, ofType: "json"), let anim = LottieAnimation.filepath(path) else { continue } EmojiPanelCell.animationCache[name] = anim } setupUI() setupRx() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() infoView.layoutIfNeeded() infoView.setCornerRadius(corners: [.topLeft ,.topRight], withCornerRadii: CGSize(width: 20, height: 20)) } } // MARK: - UICollectionViewDelegate (page control) extension InteractionView: UICollectionViewDelegate { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { guard scrollView == emojiCollectionView else { return } let page = Int(scrollView.contentOffset.x / scrollView.bounds.width) emojiPageControl.currentPage = page } } // MARK: - EmojiPanelCell final class EmojiPanelCell: UICollectionViewCell { static var animationCache: [String: LottieAnimation] = [:] private let lottieView: LottieAnimationView = { let v = LottieAnimationView() v.contentMode = .scaleAspectFit // v.loopMode = .loop return v }() lazy var lockView: UIView = { let view = UIView() view.backgroundColor = .clear view.isHidden = true let icon = UIImageView(image: UIImage(named: "Home/emoji_lock")) view.addSubview(icon) icon.layoutChain .edges() return view }() override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(lottieView) contentView.addSubview(lockView) lottieView.layoutChain.edges() lockView.layoutChain .right() .bottom() .width(14) .height(14) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(path: String) { lottieView.stop() if let cached = Self.animationCache[path] { lottieView.animation = cached } else { lottieView.animation = LottieAnimation.filepath(path) } lottieView.currentProgress = 0 lockView.isHidden = AppContextManager.shared.vip > 1 } func playAnimation() { lottieView.play() } func stopAnimation() { lottieView.stop() } override func prepareForReuse() { super.prepareForReuse() lottieView.stop() lottieView.animation = nil } } // MARK: - 表情文件列表(共用) extension UIView { static var emojiFileNames: [String] = { let paths = Bundle.main.paths(forResourcesOfType: "json", inDirectory: nil) return paths .compactMap { $0.components(separatedBy: "/").last } .filter { $0.hasPrefix("normal_") && $0.hasSuffix(".json") } .map { $0.replacingOccurrences(of: ".json", with: "") } .sorted() }() static var funEmojiFileNames: [String] = { let paths = Bundle.main.paths(forResourcesOfType: "json", inDirectory: nil) return paths .compactMap { $0.components(separatedBy: "/").last } .filter { $0.hasPrefix("fun_") && $0.hasSuffix(".json") } .map { $0.replacingOccurrences(of: ".json", with: "") } .sorted() }() }