583 lines
17 KiB
Swift
583 lines
17 KiB
Swift
//
|
||
// HomeView.swift
|
||
// QuickLocation
|
||
//
|
||
// Created by 八条 on 2026/5/27.
|
||
//
|
||
|
||
import UIKit
|
||
import RxSwift
|
||
import RxCocoa
|
||
import Lottie
|
||
#if !targetEnvironment(simulator)
|
||
import MAMapKit
|
||
#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))
|
||
|
||
// 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)
|
||
|
||
// tableView 到达顶部继续下拉时,改由 GroupMemberView 的 pan 手势接管
|
||
groupMemberView.tableView.rx.contentOffset
|
||
.subscribe(onNext: { [weak self] offset in
|
||
guard let self = self else { return }
|
||
if self.isSubCanScroll {
|
||
if offset.y <= 0 {
|
||
self.isSubCanScroll = false
|
||
self.groupMemberView.tableView.setContentOffset(.zero, animated: false)
|
||
}
|
||
} else if offset.y != 0 {
|
||
self.groupMemberView.tableView.setContentOffset(.zero, animated: false)
|
||
}
|
||
})
|
||
.disposed(by: disposeBag)
|
||
|
||
// 收回互动view
|
||
quickMessageView.closeBtn.rx.tap.subscribe(onNext: { _ in
|
||
self.dismissMemberPanel()
|
||
}).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(toolsView)
|
||
addSubview(searchLottieView)
|
||
addSubview(locationView)
|
||
locationView.addSubview(locationIconView)
|
||
|
||
#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)
|
||
|
||
toolsView.layoutChain
|
||
.left(23)
|
||
.centerY()
|
||
.width(40)
|
||
|
||
bubbleView.layoutChain
|
||
.top(12)
|
||
.height(58)
|
||
|
||
bubbleIcon.layoutChain
|
||
.top()
|
||
.centerX()
|
||
.width(28)
|
||
.height(28)
|
||
|
||
bubbleLab.layoutChain
|
||
.topToBottomOfView(bubbleIcon, offset: 4)
|
||
.edgesHorzontal()
|
||
|
||
signInView.layoutChain.height(58)
|
||
|
||
signInIcon.layoutChain
|
||
.top()
|
||
.centerX()
|
||
.width(28)
|
||
.height(28)
|
||
|
||
signInLab.layoutChain
|
||
.topToBottomOfView(signInIcon, offset: 4)
|
||
.edgesHorzontal()
|
||
|
||
sosView.layoutChain
|
||
.height(56)
|
||
|
||
sosIcon.layoutChain
|
||
.top()
|
||
.centerX()
|
||
.width(28)
|
||
.height(28)
|
||
|
||
sosLab.layoutChain
|
||
.topToBottomOfView(sosIcon, offset: 4)
|
||
.edgesHorzontal()
|
||
|
||
searchLottieView.layoutChain
|
||
.centerY()
|
||
.right()
|
||
.width(100)
|
||
.height(100)
|
||
|
||
locationView.layoutChain
|
||
.topToBottomOfView(searchLottieView, offset: 8)
|
||
.right(15)
|
||
.bottomToView(toolsView)
|
||
.width(40)
|
||
.height(40)
|
||
|
||
locationIconView.layoutChain
|
||
.centerX()
|
||
.centerY()
|
||
|
||
// 圈子成员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
|
||
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
|
||
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()
|
||
|
||
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, newTop >= groupMemberUpLimit {
|
||
// 还未滑到顶部,让 tableView 处理
|
||
return
|
||
}
|
||
// 到达顶部继续下拉 → 关闭子滚动,交由 view 的 pan 处理
|
||
isSubCanScroll = false
|
||
panStartTop = groupMemberView.frame.minY
|
||
}
|
||
|
||
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
|
||
}()
|
||
|
||
// MARK: - 侧边工具栏
|
||
lazy var toolsView: UIStackView = {
|
||
let view = UIStackView(arrangedSubviews: [bubbleView, signInView, sosView])
|
||
view.axis = .vertical
|
||
view.distribution = .fillEqually
|
||
view.alignment = .center
|
||
view.spacing = 0
|
||
view.backgroundColor = .black.withAlphaComponent(0.5)
|
||
view.cornerRadius = 20
|
||
return view
|
||
}()
|
||
|
||
// 气泡
|
||
lazy var bubbleView: UIView = {
|
||
let view = UIView()
|
||
view.backgroundColor = .clear
|
||
view.addSubview(bubbleIcon)
|
||
view.addSubview(bubbleLab)
|
||
|
||
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)
|
||
view.addSubview(signInLab)
|
||
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
|
||
}
|
||
}
|