// // HomeView.swift // QuickLocation // // Created by 八条 on 2026/5/27. // import UIKit import RxSwift import RxCocoa import Lottie #if !targetEnvironment(simulator) import AMapNaviKit import MarqueeLabel #endif class HomeView: UIView { var disposeBag = DisposeBag() let groupMemberView = GroupMemberView(frame: .zero) let quickMessageView = QuickMessageView(frame: CGRectMake(0, kScreenHeight, kScreenWidth, 93)) let interactionView = InteractionView(frame: CGRectMake(0, kScreenHeight, kScreenWidth, 384)) var gpsSignalViewList: [UIView] = [] /// 根据信号格数更新信号条颜色 func updateGPSSignal(bars: Int) { let filled = max(0, min(bars, gpsSignalViewList.count)) for (i, view) in gpsSignalViewList.enumerated() { view.backgroundColor = i < filled ? UIColor(hexStr: "#4ED178") : UIColor(hexStr: "#E0E0E0") } } // MARK: - groupMemberView 拖拽 private var groupMemberTopConstraint: NSLayoutConstraint? private var groupMemberUpLimit: CGFloat = 0 private var groupMemberDownLimit: CGFloat = 0 private var isGroupMemberLimitsSet = false private var panStartTop: CGFloat = 0 /// 内嵌 tableView 是否可滑动(最大化时才可) private var isSubCanScroll = false // MARK: - Map #if !targetEnvironment(simulator) lazy var mapView: MAMapView! = { let mv = MAMapView() mv.zoomLevel = 16 mv.showsUserLocation = true mv.userTrackingMode = .none mv.showsCompass = false mv.showsScale = false mv.isRotateEnabled = false mv.isRotateCameraEnabled = false return mv }() #else lazy var mapPlaceholderView: UIView = { let v = UIView() v.backgroundColor = UIColor(hexStr: "#EDEDED") let label = UILabel() label.text = "Map requires a real device" label.font = UIFont.systemFont(ofSize: 14) label.textColor = UIColor(hexStr: "#999999") label.textAlignment = .center v.addSubview(label) label.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: v.centerXAnchor), label.centerYAnchor.constraint(equalTo: v.centerYAnchor) ]) return v }() #endif /// 面板关闭回调 var onDismissPanel: (() -> Void)? // MARK: - Setup private func setupRx() { quickMessageView.closeBtn.rx.tap .subscribe(onNext: { [weak self] _ in self?.dismissMemberPanel() self?.onDismissPanel?() }) .disposed(by: disposeBag) // 嵌套滑动协调:contentOffset 只复位位置,scroll 状态由 pan handler + panVelocity 管理 groupMemberView.tableView.rx.contentOffset .subscribe(onNext: { [weak self] offset in guard let self = self else { return } if !self.isSubCanScroll && offset.y != 0 { self.groupMemberView.tableView.setContentOffset(.zero, animated: false) } }) .disposed(by: disposeBag) // 查找位置 searchLottieView.rx.tapGesture.subscribe { _ in AppRouter.push(Route.searchLocation) }.disposed(by: disposeBag) } private func setupUI() { #if !targetEnvironment(simulator) addSubview(mapView) #else addSubview(mapPlaceholderView) #endif addSubview(navBarBg) addSubview(avatarImgView) addSubview(groupView) groupView.addSubview(groupIconView) groupView.addSubview(groupNameLab) groupView.addSubview(groupArrowIconView) addSubview(messageView) addSubview(messageStackView) addSubview(toolsView) addSubview(searchLottieView) addSubview(locationView) locationView.addSubview(locationIconView) addSubview(emojiPopView) addSubview(sosPopView) #if !targetEnvironment(simulator) mapView.layoutChain .top() .edges(excludingEdge: .top) sendSubviewToBack(mapView) #else mapPlaceholderView.layoutChain .topToBottomOfView(navBarBg) .left(0).right(0).bottom(0) #endif navBarBg.layoutChain .edges(excludingEdge: .bottom) .heightToWidth(160/375) avatarImgView.layoutChain .top(59) .left(15) .width(36) .heightToWidth(1) groupView.layoutChain .centerY(avatarImgView) .height(36) .centerX() .width(185, relation: .greaterThanOrEqual) groupIconView.layoutChain .left(11) .centerY() .width(30) .height(30) groupArrowIconView.layoutChain .right(15) .centerY() .width(15) .height(8.5) groupNameLab.layoutChain .edgesVertical() .leftToRightOfView(groupIconView) .rightToLeftOfView(groupArrowIconView) messageView.layoutChain .right(15) .centerY(groupView) .width(36) .height(36) messageStackView.layoutChain .topToBottomOfView(messageView, offset: 23) .edgesHorzontal(15) toolsView.layoutChain .left(15) .bottom(kScreenHeight / 2 - 58) .width(40) bubbleView.layoutChain.top(15).height(58) signInView.layoutChain.height(58) sosView.layoutChain .height(56) sosIcon.layoutChain .top() .centerX() .width(28) .height(28) sosLab.layoutChain .topToBottomOfView(sosIcon, offset: 4) .edgesHorzontal() searchLottieView.layoutChain .centerY(toolsView) .right() .width(100) .height(100) locationView.layoutChain .topToBottomOfView(searchLottieView, offset: 8) .right(15) .bottomToView(toolsView) .width(40) .height(40) locationIconView.layoutChain .centerX() .centerY() emojiPopView.layoutChain .centerX().centerY() .width(180).height(180) sosPopView.layoutChain .bottomToView(toolsView) .leftToRightOfView(toolsView) .height(57).width(57) // 圈子成员View addSubview(groupMemberView) groupMemberView.layoutChain .topToBottomOfView(toolsView, offset: 10) .edgesHorzontal() .bottom() groupMemberTopConstraint = groupMemberView.jh_constraint( .top, toAttribute: .bottom, otherView: toolsView, relation: .equal ) let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGroupMemberPan(_:))) pan.delegate = self groupMemberView.addGestureRecognizer(pan) // 底部弹出面板(quickMessageView 在上,interactionView 在下) addSubview(interactionView) addSubview(quickMessageView) } // MARK: - 底部面板显隐 func showMemberPanel(member: CircleMember) { interactionView.configure(member: member) quickMessageView.isHidden = false interactionView.isHidden = false toolsView.isHidden = true quickMessageView.frame.origin.y = kScreenHeight - 384 - 93 interactionView.frame.origin.y = kScreenHeight - 384 } func dismissMemberPanel() { quickMessageView.frame.origin.y = kScreenHeight interactionView.frame.origin.y = kScreenHeight quickMessageView.isHidden = true interactionView.isHidden = true toolsView.isHidden = false onDismissPanel?() } override init(frame: CGRect) { super.init(frame: .zero) backgroundColor = .white setupUI() setupRx() searchLottieView.play() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Layout override func layoutSubviews() { super.layoutSubviews() noticeView.layoutIfNeeded() noticeInfoView.setGradientLayer(frame: noticeInfoView.bounds, startPoint: CGPoint(x: 0, y: 0.5), endPoint: CGPoint(x: 1, y: 0.5), colors: [UIColor(hexStr: "#FFFFFF", alpha: 0), UIColor(hexStr: "#FFFFFF"), UIColor(hexStr: "#FFFFFF", alpha: 0)], locations: [0, 0.5, 1]) if !isGroupMemberLimitsSet { isGroupMemberLimitsSet = true groupMemberDownLimit = groupMemberView.frame.minY groupMemberUpLimit = groupView.frame.maxY + 20 } } // MARK: - Pan Gesture @objc private func handleGroupMemberPan(_ pan: UIPanGestureRecognizer) { guard isGroupMemberLimitsSet, let topConstraint = groupMemberTopConstraint else { return } switch pan.state { case .began: panStartTop = groupMemberView.frame.minY case .changed: let newTop = panStartTop + pan.translation(in: self).y // 最大化状态下,tableView 可以向下滑时,不干扰自身滑动; // 只有 tableView 到达顶部继续下拉时,才切换为 view 拖拽 if isSubCanScroll { let tableViewOffset = self.groupMemberView.tableView.contentOffset.y if tableViewOffset > 0 { // 内容正在滑动,不移动 GroupMemberView return } // 内容滑到顶部 → 切回 Pan 拖拽(用户下拉时 velocity > 0) if pan.velocity(in: self).y > 0 || groupMemberView.frame.minY > groupMemberUpLimit + 1 { isSubCanScroll = false panStartTop = groupMemberView.frame.minY } } else { // GroupMemberView 在顶部且继续上滑 → 激活内容滑动 if groupMemberView.frame.minY <= groupMemberUpLimit && newTop <= groupMemberUpLimit { isSubCanScroll = true panStartTop = groupMemberView.frame.minY topConstraint.constant = groupMemberUpLimit - groupMemberDownLimit + 10 return } } let clamped = max(groupMemberUpLimit, min(groupMemberDownLimit, newTop)) topConstraint.constant = clamped - groupMemberDownLimit + 10 case .ended, .cancelled: let velocity = pan.velocity(in: self) let isNearUp = abs(groupMemberView.frame.minY - groupMemberUpLimit) < abs(groupMemberView.frame.minY - groupMemberDownLimit) let target: CGFloat if abs(velocity.y) > 200 { target = velocity.y < 0 ? groupMemberUpLimit : groupMemberDownLimit } else { target = isNearUp ? groupMemberUpLimit : groupMemberDownLimit } topConstraint.constant = target - groupMemberDownLimit + 10 UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) { self.layoutIfNeeded() } completion: { _ in let atTop = target == self.groupMemberUpLimit self.isSubCanScroll = atTop // 未最大化时重置 tableView 到顶部 if !atTop { self.groupMemberView.tableView.contentOffset.y = 0 } } default: break } } /// 收起 GroupMemberView(选中成员后调用) func dismissGroupMemberView() { guard isGroupMemberLimitsSet, let topConstraint = groupMemberTopConstraint else { return } isSubCanScroll = false topConstraint.constant = groupMemberDownLimit - groupMemberDownLimit + 10 groupMemberView.tableView.setContentOffset(.zero, animated: false) UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { self.layoutIfNeeded() } } // MARK: - UI Components lazy var navBarBg: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/navBar_bg") view.contentMode = .scaleAspectFill return view }() lazy var avatarImgView: UIImageView = { let view = UIImageView() view.backgroundColor = .lightGray view.contentMode = .scaleAspectFill view.cornerRadius = 18 return view }() // 圈子选择 lazy var groupView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 18 return view }() lazy var groupIconView: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/group") view.backgroundColor = .clear view.contentMode = .scaleAspectFill return view }() lazy var groupNameLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 16, weight: .medium) label.textColor = UIColor(hexStr: "#0F2846") label.textAlignment = .center return label }() lazy var groupArrowIconView: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/arrow_down") view.backgroundColor = .clear view.contentMode = .scaleAspectFill return view }() /// 消息 lazy var messageView: UIView = { let view = UIView() view.backgroundColor = .white view.cornerRadius = 18 view.clipsToBounds = false view.addSubview(messageIcon) view.addSubview(messageDotView) messageIcon.layoutChain .centerX().centerY() messageDotView.layoutChain .top() .right() .width(10) .height(10) return view }() lazy var messageIcon: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/message") view.backgroundColor = .clear view.contentMode = .scaleAspectFill return view }() lazy var messageDotView: UIView = { let view = UIView() view.backgroundColor = UIColor(hexStr: "#FD5E61") view.cornerRadius = 5 return view }() lazy var messageStackView: UIStackView = { let view = UIStackView(arrangedSubviews: [tipsView, messageBubbleView]) view.axis = .vertical view.alignment = .leading view.spacing = 5 view.backgroundColor = .clear return view }() lazy var tipsView: UIView = { let view = UIView() view.backgroundColor = .clear view.layoutChain.height(22) view.addSubview(gpsSignalView) gpsSignalView.layoutChain .left() .centerY() view.addSubview(noticeView) noticeView.layoutChain .leftToRightOfView(gpsSignalView, offset: 0) .right(15) .edgesVertical() return view }() // MARK: - 卫星信号 lazy var gpsSignalView: UIView = { let view = UIView() view.cornerRadius = 5 view.backgroundColor = .black.withAlphaComponent(0.4) let signalView1 = UIView() signalView1.backgroundColor = UIColor(hexStr: "#E6E6E6") view.addSubview(signalView1) signalView1.layoutChain .left(7) .bottom(5) .width(3) .height(4) let signalView2 = UIView() signalView2.backgroundColor = UIColor(hexStr: "#E6E6E6") view.addSubview(signalView2) signalView2.layoutChain .leftToRightOfView(signalView1, offset: 2) .bottomToView(signalView1) .width(3) .height(6) let signalView3 = UIView() signalView3.backgroundColor = UIColor(hexStr: "#E6E6E6") view.addSubview(signalView3) signalView3.layoutChain .leftToRightOfView(signalView2, offset: 2) .bottomToView(signalView1) .width(3) .height(8) let signalView4 = UIView() signalView4.backgroundColor = UIColor(hexStr: "#E6E6E6") view.addSubview(signalView4) signalView4.layoutChain .leftToRightOfView(signalView3, offset: 2) .bottomToView(signalView1) .width(3) .height(10) let label = UILabel() label.text = "卫星信号" label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = .white view.addSubview(label) label.layoutChain .leftToRightOfView(signalView4, offset: 2) .edgesVertical(4) .right(7) gpsSignalViewList = [signalView1, signalView2, signalView3, signalView4] return view }() // MARK: - 公告 lazy var noticeView: UIView = { let view = UIView() view.backgroundColor = .clear view.addSubview(noticeInfoView) noticeInfoView.layoutChain .left() .right() .edgesVertical() view.addSubview(noticeCloseBtn) noticeCloseBtn.layoutChain .right() .width(16) .height(16) .centerY() return view }() lazy var noticeInfoView: UIView = { let view = UIView() view.backgroundColor = .clear // let titleLab = UILabel() // titleLab.text = "公告信息" // titleLab.font = .systemFont(ofSize: 10, weight: .medium) // titleLab.textColor = UIColor(hexStr: "#0F2948") // view.addSubview(titleLab) // titleLab.layoutChain // .left(16) // .centerY() // .width(40) view.addSubview(noticeLab) noticeLab.layoutChain .left(16) .centerY() .right(24) return view }() lazy var noticeLab: CallbackMarqueeLabel = { let label = CallbackMarqueeLabel() label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = UIColor(hexStr: "#0F2948") return label }() lazy var noticeCloseBtn: UIButton = { let btn = UIButton() btn.setImage(UIImage(named: "Common/x_black"), for: .normal) btn.backgroundColor = .white btn.cornerRadius = 8 btn.extendEdgeInsets = UIEdgeInsets(top: 15, left: 15, bottom: 15, right: 15) btn.rx.tap.subscribe(onNext: { [weak self] _ in self?.noticeView.isHidden = true }).disposed(by: disposeBag) return btn }() // MARK: - 消息气泡 lazy var messageBubbleView: UIView = { let view = UIView() view.backgroundColor = .white.withAlphaComponent(0.8) view.cornerRadius = 10 view.isHidden = true view.addSubview(messageLab) messageLab.layoutChain .edges(all: 12) return view }() lazy var messageLab: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12, weight: .medium) label.textColor = UIColor(hexStr: "#0F2846") return label }() // MARK: - SOS lazy var sosPopView: LottieAnimationView = { let view = LottieAnimationView(name: "siren") view.loopMode = .loop view.isHidden = true return view }() // MARK: - 表情展示 lazy var emojiPopView: LottieAnimationView = { let view = LottieAnimationView() view.loopMode = .playOnce view.isHidden = true return view }() // MARK: - 侧边工具栏 lazy var toolsView: UIStackView = { let view = UIStackView(arrangedSubviews: [bubbleView, signInView, sosView]) view.axis = .vertical view.distribution = .equalSpacing view.alignment = .center view.backgroundColor = .black.withAlphaComponent(0.5) view.cornerRadius = 20 return view }() // 气泡 lazy var bubbleView: UIView = { let view = UIView() view.backgroundColor = .clear view.addSubview(bubbleIcon) bubbleIcon.layoutChain .top() .centerX() .width(28) .height(28) view.addSubview(bubbleLab) bubbleLab.layoutChain .topToBottomOfView(bubbleIcon, offset: 4) .edgesHorzontal() let lineView = UIView() lineView.backgroundColor = .white view.addSubview(lineView) lineView.layoutChain .width(12) .height(2) .centerX() .bottom(7) return view }() lazy var bubbleIcon: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/bubble") view.backgroundColor = .clear view.contentMode = .scaleAspectFill return view }() lazy var bubbleLab: UILabel = { let label = UILabel() label.text = "气泡" label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = .white label.textAlignment = .center return label }() // 签到 lazy var signInView: UIView = { let view = UIView() view.backgroundColor = .clear view.addSubview(signInIcon) signInIcon.layoutChain .top() .centerX() .width(28) .height(28) view.addSubview(signInLab) signInLab.layoutChain .topToBottomOfView(signInIcon, offset: 4) .edgesHorzontal() let lineView = UIView() lineView.backgroundColor = .white view.addSubview(lineView) lineView.layoutChain .width(12) .height(2) .centerX() .bottom(7) return view }() lazy var signInIcon: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/signIn") view.backgroundColor = .clear view.contentMode = .scaleAspectFill return view }() lazy var signInLab: UILabel = { let label = UILabel() label.text = "签到" label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = .white label.textAlignment = .center return label }() // SOS lazy var sosView: UIView = { let view = UIView() view.backgroundColor = .clear view.addSubview(sosIcon) view.addSubview(sosLab) return view }() lazy var sosIcon: UIImageView = { let view = UIImageView() view.image = UIImage(named: "Home/sos") view.backgroundColor = .clear view.contentMode = .scaleAspectFill return view }() lazy var sosLab: UILabel = { let label = UILabel() label.text = "SOS" label.font = .systemFont(ofSize: 10, weight: .medium) label.textColor = .white label.textAlignment = .center return label }() // MARK: - 查位置 lazy var searchLottieView: LottieAnimationView = { let view = LottieAnimationView(name: "home_search") view.loopMode = .loop return view }() // MARK: - 定位按钮 lazy var locationView: UIView = { let view = UIView() view.backgroundColor = .black.withAlphaComponent(0.4) view.cornerRadius = 20 return view }() lazy var locationIconView: UIImageView = { let view = UIImageView() view.backgroundColor = .clear view.image = UIImage(named: "Home/location") return view }() } // MARK: - UIGestureRecognizerDelegate extension HomeView: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { // 允许 GroupMemberView 的 pan 与 tableView 的 pan 同时识别 // 滚动状态由 isSubCanScroll + contentOffset 控制,不影响手势判定 return true } } // MARK: - CallbackMarqueeLabel class CallbackMarqueeLabel: MarqueeLabel { // 闭包回调对外抛出事件 var onScrollLoopComplete: (() -> Void)? var onScrollStart: (() -> Void)? // 滚动开始 override func labelWillBeginScroll() { super.labelWillBeginScroll() onScrollStart?() } // 单次滚动一圈完成 override func labelReturnedToHome(_ finished: Bool) { super.labelReturnedToHome(finished) // 只处理正常滚动结束,过滤中途重置 guard finished else { return } onScrollLoopComplete?() } }