jsdw_ios/QuickLocation/Section/Map/MapView.swift

665 lines
21 KiB
Swift

//
// 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
}
}