// // MapView.swift // QuickLocation // // Created based on Lanhu design: 1.主页.-成员 // import UIKit import RxSwift #if !targetEnvironment(simulator) import MAMapKit #endif final class MapView: UIView { var disposeBag = DisposeBag() // MARK: - Design Constants (iOS points, from Lanhu design "1.主页.-成员" 375pt wide) private let topNavHeight: CGFloat = 44 private let controlButtonSize: CGFloat = 14 private let controlStripWidth: CGFloat = 20 private let controlStripCornerRadius: CGFloat = 10 private let panelHeaderHeight: CGFloat = 70 private var bottomPanelHeightConstraint: NSLayoutConstraint? // MARK: - Fonts private let douyuFont: (CGFloat) -> UIFont? = { size in UIFont(name: "DOUYU Font", size: size) } private let pingFangMedium: (CGFloat) -> UIFont = { size in UIFont(name: "PingFangSC-Medium", size: size) ?? UIFont.systemFont(ofSize: size, weight: .medium) } private let pingFangSemibold: (CGFloat) -> UIFont = { size in UIFont(name: "PingFangSC-Semibold", size: size) ?? UIFont.systemFont(ofSize: size, weight: .semibold) } private let pingFangRegular: (CGFloat) -> UIFont = { size in UIFont(name: "PingFangSC-Regular", size: size) ?? UIFont.systemFont(ofSize: size, weight: .regular) } // MARK: - Top Navigation Bar private(set) lazy var topNavBar: UIView = { let v = UIView() v.backgroundColor = .clear return v }() private(set) lazy var avatarButton: UIButton = { let btn = UIButton(type: .custom) btn.layer.cornerRadius = 18 btn.clipsToBounds = true btn.backgroundColor = UIColor(hexStr: "#E0E0E0") btn.setImage(UIImage(named: "map_avatar_1"), for: .normal) return btn }() private lazy var titleLabel: UILabel = { let l = UILabel() l.text = "我的圈子" l.font = pingFangMedium(14) l.textColor = UIColor(hexStr: "#0F2846") return l }() private lazy var goldCircleIcon: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Map/map_circle_icon") iv.contentMode = .scaleAspectFit return iv }() private(set) lazy var dropdownArrowButton: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(systemName: "chevron.down"), for: .normal) btn.tintColor = UIColor(hexStr: "#0F2846") return btn }() // MARK: - Map View #if !targetEnvironment(simulator) lazy var mapView: MAMapView = { let mv = MAMapView() mv.zoomLevel = 15 mv.minZoomLevel = 3 mv.maxZoomLevel = 20 mv.showsUserLocation = true mv.userTrackingMode = .follow 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 // MARK: - Map Controls (Left Side) private lazy var controlStrip: UIView = { let v = UIView() v.backgroundColor = UIColor.black.withAlphaComponent(0.5) v.layer.cornerRadius = controlStripCornerRadius v.clipsToBounds = true return v }() private func makeControlButton(icon: String, text: String) -> UIView { let container = UIView() let circleBtn = UIView() circleBtn.backgroundColor = .white circleBtn.layer.cornerRadius = controlButtonSize / 2 container.addSubview(circleBtn) let imgView = UIImageView() imgView.image = UIImage(named: "Map/\(icon)") imgView.contentMode = .scaleAspectFit circleBtn.addSubview(imgView) let label = UILabel() label.text = text label.font = pingFangMedium(8) label.textColor = .white label.textAlignment = .center container.addSubview(label) circleBtn.layoutChain .top(0).centerX() .size(CGSize(width: controlButtonSize, height: controlButtonSize)) imgView.layoutChain .center().size(CGSize(width: 8, height: 8)) label.layoutChain .topToBottomOfView(circleBtn, offset: 3) .centerX().bottom() return container } private(set) lazy var sosButton: UIView = makeControlButton(icon: "map_btn_sos", text: "SOS") private(set) lazy var checkinButton: UIView = makeControlButton(icon: "map_btn_checkin", text: "签到") private(set) lazy var bubbleButton: UIView = makeControlButton(icon: "map_btn_trip", text: "气泡") // MARK: - Siren Button (left of control strip) private(set) lazy var sirenButton: UIButton = { let btn = UIButton(type: .custom) btn.backgroundColor = .clear btn.setImage(UIImage(named: "Map/map_member_bubble_large"), for: .normal) btn.contentMode = .scaleAspectFit return btn }() // MARK: - Location Button (Bottom-Right) private(set) lazy var locationButton: UIButton = { let btn = UIButton(type: .custom) btn.backgroundColor = UIColor.black.withAlphaComponent(0.4) btn.layer.cornerRadius = 10 btn.setImage(UIImage(named: "Map/map_current_location"), for: .normal) btn.contentMode = .center return btn }() // MARK: - Announcement Bar private(set) lazy var announcementBar: UIView = { let v = UIView() v.backgroundColor = UIColor.white v.layer.cornerRadius = 11 v.layer.shadowColor = UIColor.black.withAlphaComponent(0.08).cgColor v.layer.shadowOffset = CGSize(width: 0, height: 2) v.layer.shadowRadius = 6 v.layer.shadowOpacity = 1 v.isHidden = true return v }() private lazy var announcementLabel: UILabel = { let l = UILabel() l.font = pingFangRegular(11) l.textColor = UIColor(hexStr: "#0F2846") return l }() private(set) lazy var announcementCloseBtn: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(systemName: "xmark"), for: .normal) btn.tintColor = UIColor(hexStr: "#999999") return btn }() // MARK: - Bottom Panel private(set) lazy var bottomPanel: UIView = { let v = UIView() v.backgroundColor = .white v.layer.cornerRadius = 16 v.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] v.layer.shadowColor = UIColor.black.withAlphaComponent(0.1).cgColor v.layer.shadowOffset = CGSize(width: 0, height: -2) v.layer.shadowRadius = 6 v.layer.shadowOpacity = 1 return v }() // Panel header background image private lazy var panelHeaderBg: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Map/map_top_panel") iv.contentMode = .scaleToFill return iv }() private lazy var circleIcon: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "Map/map_circle_icon") iv.contentMode = .scaleAspectFit return iv }() private lazy var memberListTitleLabel: UILabel = { let l = UILabel() l.text = "圈子成员" l.font = douyuFont(16) ?? pingFangSemibold(16) l.textColor = .white return l }() private(set) lazy var memberCountLabel: UILabel = { let l = UILabel() l.font = pingFangRegular(11) l.textColor = UIColor(hexStr: "#0F2846").withAlphaComponent(0.7) return l }() private(set) lazy var refreshButton: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "Map/map_btn_menu"), for: .normal) btn.contentMode = .scaleAspectFit return btn }() private(set) lazy var inviteButton: UIButton = { let btn = UIButton(type: .custom) btn.setTitle("邀请加入", for: .normal) btn.titleLabel?.font = pingFangMedium(11) btn.setTitleColor(.white, for: .normal) btn.backgroundColor = UIColor.white.withAlphaComponent(0.2) btn.layer.cornerRadius = 12 return btn }() // MARK: - Member List private(set) lazy var memberListScrollView: UIScrollView = { let sv = UIScrollView() sv.showsVerticalScrollIndicator = false sv.alwaysBounceVertical = true sv.backgroundColor = .white return sv }() private lazy var memberListStackView: UIStackView = { let sv = UIStackView() sv.axis = .vertical sv.spacing = 0 return sv }() // MARK: - Init override init(frame: CGRect) { super.init(frame: frame) setupUI() setupLayout() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Setup private func setupUI() { backgroundColor = .white addSubview(topNavBar) topNavBar.addSubview(avatarButton) topNavBar.addSubview(titleLabel) topNavBar.addSubview(goldCircleIcon) topNavBar.addSubview(dropdownArrowButton) #if !targetEnvironment(simulator) addSubview(mapView) #else addSubview(mapPlaceholderView) #endif addSubview(controlStrip) controlStrip.addSubview(sosButton) controlStrip.addSubview(checkinButton) controlStrip.addSubview(bubbleButton) addSubview(sirenButton) addSubview(locationButton) addSubview(announcementBar) announcementBar.addSubview(announcementLabel) announcementBar.addSubview(announcementCloseBtn) addSubview(bottomPanel) bottomPanel.addSubview(panelHeaderBg) bottomPanel.addSubview(circleIcon) bottomPanel.addSubview(memberListTitleLabel) bottomPanel.addSubview(memberCountLabel) bottomPanel.addSubview(refreshButton) bottomPanel.addSubview(inviteButton) bottomPanel.addSubview(memberListScrollView) memberListScrollView.addSubview(memberListStackView) } private func setupLayout() { topNavBar.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ topNavBar.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), topNavBar.leadingAnchor.constraint(equalTo: leadingAnchor), topNavBar.trailingAnchor.constraint(equalTo: trailingAnchor), topNavBar.heightAnchor.constraint(equalToConstant: topNavHeight) ]) avatarButton.layoutChain .left(12).centerY() .size(CGSize(width: 36, height: 36)) titleLabel.layoutChain.center() goldCircleIcon.layoutChain .leftToRightOfView(titleLabel, offset: 4) .centerY(titleLabel) .size(CGSize(width: 18, height: 14)) dropdownArrowButton.layoutChain .right(12).centerY() .size(CGSize(width: 20, height: 20)) #if !targetEnvironment(simulator) mapView.layoutChain .topToBottomOfView(topNavBar) .left(0).right(0).bottom(0) #else mapPlaceholderView.layoutChain .topToBottomOfView(topNavBar) .left(0).right(0).bottom(0) #endif // Left control strip controlStrip.layoutChain .left(8) .topToBottomOfView(topNavBar, offset: 12) .width(controlStripWidth) sosButton.layoutChain .top(10).centerX() .left(0).right(0) .height(30) checkinButton.layoutChain .topToBottomOfView(sosButton, offset: 12) .centerX() .left(0).right(0) .height(30) bubbleButton.layoutChain .topToBottomOfView(checkinButton, offset: 12) .centerX() .left(0).right(0) .height(30) .bottom(10) // Siren (alarm) button to the left of control strip sirenButton.layoutChain .leftToRightOfView(controlStrip, offset: 6) .centerY(controlStrip) .size(CGSize(width: 28, height: 28)) // Location button locationButton.layoutChain .right(12) .bottomToTopOfView(bottomPanel, offset: -12) .size(CGSize(width: 20, height: 20)) // Announcement bar announcementBar.layoutChain .left(24).right(24) .topToBottomOfView(topNavBar, offset: 8) .height(22) announcementLabel.layoutChain .left(12).centerY() announcementCloseBtn.layoutChain .right(8).centerY() .size(CGSize(width: 10, height: 10)) // Bottom panel bottomPanel.layoutChain .left(0).right(0).bottom(0) bottomPanel.translatesAutoresizingMaskIntoConstraints = false let hc = bottomPanel.heightAnchor.constraint(equalToConstant: bottomPanelHeight(for: bounds.height)) hc.isActive = true bottomPanelHeightConstraint = hc // Panel header panelHeaderBg.layoutChain .top(0).left(0).right(0) .height(panelHeaderHeight) circleIcon.layoutChain .left(12).top(12) .size(CGSize(width: 26, height: 26)) memberListTitleLabel.layoutChain .leftToRightOfView(circleIcon, offset: 6) .centerY(circleIcon) memberCountLabel.layoutChain .leftToRightOfView(circleIcon, offset: 6) .topToBottomOfView(memberListTitleLabel, offset: 2) refreshButton.layoutChain .rightToLeftOfView(inviteButton, offset: -12) .centerY(circleIcon) .size(CGSize(width: 16, height: 16)) inviteButton.layoutChain .right(12).centerY(circleIcon) .size(CGSize(width: 64, height: 24)) // Member list scroll view memberListScrollView.layoutChain .topToBottomOfView(panelHeaderBg) .left(0).right(0).bottom(0) memberListStackView.layoutChain .top(0).left(0) .widthToView(memberListScrollView) } override func layoutSubviews() { super.layoutSubviews() bottomPanelHeightConstraint?.constant = bottomPanelHeight(for: bounds.height) } /// Bottom panel is ~42% of view height, capped between 280-350pt private func bottomPanelHeight(for totalHeight: CGFloat) -> CGFloat { let ratio: CGFloat = 0.42 return min(350, max(280, totalHeight * ratio)) } // MARK: - Configure Member List func configureMemberList(with members: [CircleMember], onSelect: @escaping (CircleMember) -> Void) { memberListStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } let onlineCount = members.filter { $0.isOnline }.count memberCountLabel.text = "共\(members.count)成员 / \(onlineCount)在线" for (index, member) in members.enumerated() { let row = MemberRowView(member: member) row.tag = index let tap = UITapGestureRecognizer(target: self, action: #selector(memberRowTapped(_:))) row.addGestureRecognizer(tap) memberListStackView.addArrangedSubview(row) if index < members.count - 1 { let sep = UIView() sep.backgroundColor = UIColor(hexStr: "#F0F0F0") memberListStackView.addArrangedSubview(sep) sep.layoutChain.height(0.5) } } let totalContentHeight = CGFloat(members.count) * MemberRowView.rowHeight + CGFloat(max(0, members.count - 1)) * 0.5 memberListScrollView.contentSize = CGSize( width: memberListScrollView.bounds.width, height: max(totalContentHeight, memberListScrollView.bounds.height) ) } @objc private func memberRowTapped(_ gesture: UITapGestureRecognizer) { // Handled via bindings in ViewController } } // MARK: - MemberRowView final class MemberRowView: UIView { static let rowHeight: CGFloat = 38 private let avatarImageView: UIImageView = { let iv = UIImageView() iv.contentMode = .scaleAspectFill iv.layer.cornerRadius = 12.5 iv.layer.borderWidth = 1 iv.layer.borderColor = UIColor.white.cgColor iv.clipsToBounds = true iv.backgroundColor = UIColor(hexStr: "#E0E0E0") return iv }() private let onlineDot: UIView = { let v = UIView() v.layer.cornerRadius = 4 v.layer.borderWidth = 1 v.layer.borderColor = UIColor.white.cgColor return v }() private let nameLabel: UILabel = { let l = UILabel() l.font = UIFont(name: "PingFangSC-Semibold", size: 12) ?? UIFont.systemFont(ofSize: 12, weight: .semibold) l.textColor = UIColor(hexStr: "#0F2846") return l }() private let addressLabel: UILabel = { let l = UILabel() l.font = UIFont(name: "PingFangSC-Regular", size: 10) ?? UIFont.systemFont(ofSize: 10) l.textColor = UIColor(hexStr: "#8D8D8D") return l }() private let statusBadge: UIView = { let v = UIView() v.layer.cornerRadius = 4 return v }() private let statusLabel: UILabel = { let l = UILabel() l.font = UIFont(name: "PingFangSC-Medium", size: 9) ?? UIFont.systemFont(ofSize: 9, weight: .medium) l.textAlignment = .center return l }() private let timeLabel: UILabel = { let l = UILabel() l.font = UIFont(name: "PingFangSC-Regular", size: 10) ?? UIFont.systemFont(ofSize: 10) l.textColor = UIColor(hexStr: "#999999") return l }() private lazy var navButton: UIButton = { let btn = UIButton(type: .custom) btn.setImage(UIImage(named: "Map/map_current_location"), for: .normal) btn.contentMode = .scaleAspectFit return btn }() let member: CircleMember init(member: CircleMember) { self.member = member super.init(frame: .zero) setupUI() configure() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { addSubview(avatarImageView) addSubview(onlineDot) addSubview(nameLabel) addSubview(addressLabel) addSubview(statusBadge) statusBadge.addSubview(statusLabel) addSubview(timeLabel) addSubview(navButton) avatarImageView.layoutChain .left(12).centerY() .size(CGSize(width: 25, height: 25)) onlineDot.layoutChain .rightToView(avatarImageView, offset: 2) .bottomToView(avatarImageView, offset: 2) .size(CGSize(width: 8, height: 8)) nameLabel.layoutChain .leftToRightOfView(avatarImageView, offset: 8) .top(6) addressLabel.layoutChain .leftToRightOfView(avatarImageView, offset: 8) .topToBottomOfView(nameLabel, offset: 2) statusBadge.layoutChain .leftToRightOfView(nameLabel, offset: 6) .centerY(nameLabel) .height(16) statusLabel.layoutChain .left(6).right(6).centerY() timeLabel.layoutChain .rightToLeftOfView(navButton, offset: -4) .centerY() navButton.layoutChain .right(12).centerY() .size(CGSize(width: 15, height: 15)) self.layoutChain.height(Self.rowHeight) } private func configure() { avatarImageView.image = UIImage(named: member.avatar) if member.isOnline { onlineDot.backgroundColor = UIColor(hexStr: "#4CD964") } else { onlineDot.backgroundColor = UIColor(hexStr: "#D4D4D4") } onlineDot.isHidden = false nameLabel.text = member.name addressLabel.text = "在 \(member.address)" if member.isOwner { statusBadge.backgroundColor = UIColor(hexStr: "#9FD9FF") statusLabel.text = "圈主" statusLabel.textColor = UIColor(hexStr: "#194045") } else if member.isOnline { statusBadge.backgroundColor = UIColor(hexStr: "#9FD9FF") statusLabel.text = "在线" statusLabel.textColor = UIColor(hexStr: "#194045") } else { statusBadge.backgroundColor = UIColor(hexStr: "#EDEDED") statusLabel.text = "离线" statusLabel.textColor = UIColor(hexStr: "#999999") } timeLabel.text = member.lastUpdateText backgroundColor = member.isCurrentUser ? UIColor(hexStr: "#EFF9FF") : .clear } }