250 lines
8.4 KiB
Swift
250 lines
8.4 KiB
Swift
//
|
|
// MemberAnnotationView.swift
|
|
// QuickLocation
|
|
//
|
|
|
|
import Foundation
|
|
#if !targetEnvironment(simulator)
|
|
import MAMapKit
|
|
|
|
final class MemberAnnotationView: MAAnnotationView {
|
|
|
|
// MARK: - Design Constants
|
|
static let avatarOuterSize: CGFloat = 50
|
|
static let avatarInnerSize: CGFloat = 48
|
|
static let nameTagWidth: CGFloat = 60
|
|
static let nameTagHeight: CGFloat = 20
|
|
static let totalHeight: CGFloat = avatarOuterSize + 4 + nameTagHeight
|
|
static let radarSize: CGFloat = 180
|
|
|
|
private let containerView: UIView = {
|
|
let v = UIView()
|
|
v.backgroundColor = .clear
|
|
return v
|
|
}()
|
|
|
|
private let avatarOuterCircle: UIView = {
|
|
let v = UIView()
|
|
v.backgroundColor = .white
|
|
v.layer.cornerRadius = avatarOuterSize / 2
|
|
v.layer.shadowColor = UIColor.black.withAlphaComponent(0.15).cgColor
|
|
v.layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
v.layer.shadowRadius = 4
|
|
v.layer.shadowOpacity = 1
|
|
return v
|
|
}()
|
|
|
|
private let avatarImageView: UIImageView = {
|
|
let iv = UIImageView()
|
|
iv.contentMode = .scaleAspectFill
|
|
iv.layer.cornerRadius = avatarInnerSize / 2
|
|
iv.layer.borderWidth = 1
|
|
iv.layer.borderColor = UIColor.white.cgColor
|
|
iv.clipsToBounds = true
|
|
iv.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
|
return iv
|
|
}()
|
|
|
|
private lazy var nameTagView: UIView = {
|
|
let v = UIView()
|
|
v.backgroundColor = .white
|
|
v.layer.cornerRadius = 4
|
|
v.layer.shadowColor = UIColor.black.withAlphaComponent(0.1).cgColor
|
|
v.layer.shadowOffset = CGSize(width: 0, height: 1)
|
|
v.layer.shadowRadius = 2
|
|
v.layer.shadowOpacity = 1
|
|
return v
|
|
}()
|
|
|
|
private lazy var nameLabel: UILabel = {
|
|
let l = UILabel()
|
|
l.font = UIFont(name: "PingFangSC-Medium", size: 11)
|
|
?? UIFont.systemFont(ofSize: 11, weight: .medium)
|
|
l.textColor = UIColor(hexStr: "#0F2846")
|
|
l.textAlignment = .center
|
|
l.lineBreakMode = .byTruncatingTail
|
|
return l
|
|
}()
|
|
|
|
// MARK: - Layers
|
|
private var headingLayer: CAShapeLayer?
|
|
private var pulseLayer: CAShapeLayer?
|
|
private var isCurrentUser: Bool = false
|
|
|
|
// MARK: - Init
|
|
override init!(annotation: MAAnnotation!, reuseIdentifier: String!) {
|
|
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
|
setupUI()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func setupUI() {
|
|
canShowCallout = false
|
|
isEnabled = true
|
|
|
|
addSubview(containerView)
|
|
|
|
containerView.addSubview(avatarOuterCircle)
|
|
avatarOuterCircle.addSubview(avatarImageView)
|
|
|
|
containerView.addSubview(nameTagView)
|
|
nameTagView.addSubview(nameLabel)
|
|
}
|
|
|
|
// MARK: - Configure
|
|
func configure(with member: CircleMember) {
|
|
avatarImageView.image = UIImage(named: "UserIcon/\(member.avatar)")
|
|
avatarImageView.backgroundColor = member.avatar.isEmpty ? .lightGray : .clear
|
|
nameLabel.text = member.name
|
|
isCurrentUser = member.isCurrentUser
|
|
|
|
if member.isCurrentUser {
|
|
configureAsCurrentUser(heading: member.heading)
|
|
} else {
|
|
configureAsMember()
|
|
}
|
|
}
|
|
|
|
private func configureAsCurrentUser(heading: Double) {
|
|
nameTagView.isHidden = true
|
|
stopPulseAnimation()
|
|
|
|
let size = Self.radarSize
|
|
bounds = CGRect(x: 0, y: 0, width: size, height: size)
|
|
centerOffset = .zero
|
|
containerView.frame = bounds
|
|
|
|
let avatarX = (size - Self.avatarOuterSize) / 2
|
|
let avatarY = (size - Self.avatarOuterSize) / 2
|
|
avatarOuterCircle.frame = CGRect(x: avatarX, y: avatarY,
|
|
width: Self.avatarOuterSize, height: Self.avatarOuterSize)
|
|
avatarImageView.frame = CGRect(
|
|
x: (Self.avatarOuterSize - Self.avatarInnerSize) / 2,
|
|
y: (Self.avatarOuterSize - Self.avatarInnerSize) / 2,
|
|
width: Self.avatarInnerSize, height: Self.avatarInnerSize
|
|
)
|
|
|
|
addHeadingIndicator(heading: heading)
|
|
}
|
|
|
|
private func configureAsMember() {
|
|
nameTagView.isHidden = false
|
|
headingLayer?.removeFromSuperlayer()
|
|
headingLayer = nil
|
|
stopPulseAnimation()
|
|
|
|
// 动态宽度
|
|
let text = nameLabel.text ?? ""
|
|
let textWidth = (text as NSString).size(withAttributes: [.font: nameLabel.font as Any]).width
|
|
let tagWidth = min(max(textWidth + 16, 44), 120)
|
|
let totalWidth = max(tagWidth, Self.avatarOuterSize)
|
|
let tagTop: CGFloat = 0
|
|
let avatarTop = Self.nameTagHeight + 4
|
|
let totalHeight = avatarTop + Self.avatarOuterSize
|
|
|
|
bounds = CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight)
|
|
containerView.frame = bounds
|
|
// avatar 中心对准 annotation 坐标
|
|
let avatarCenterY = avatarTop + Self.avatarOuterSize / 2
|
|
centerOffset = CGPoint(x: 0, y: avatarCenterY - totalHeight / 2)
|
|
|
|
// nameTag 在头像上面
|
|
nameTagView.frame = CGRect(
|
|
x: (totalWidth - tagWidth) / 2, y: tagTop,
|
|
width: tagWidth, height: Self.nameTagHeight
|
|
)
|
|
nameLabel.frame = nameTagView.bounds.insetBy(dx: 6, dy: 0)
|
|
|
|
// 头像在 nameTag 下面
|
|
avatarOuterCircle.frame = CGRect(
|
|
x: (totalWidth - Self.avatarOuterSize) / 2, y: avatarTop,
|
|
width: Self.avatarOuterSize, height: Self.avatarOuterSize
|
|
)
|
|
avatarImageView.frame = CGRect(
|
|
x: (Self.avatarOuterSize - Self.avatarInnerSize) / 2,
|
|
y: (Self.avatarOuterSize - Self.avatarInnerSize) / 2,
|
|
width: Self.avatarInnerSize, height: Self.avatarInnerSize
|
|
)
|
|
}
|
|
|
|
// MARK: - Heading
|
|
func updateHeading(_ heading: Double) {
|
|
headingLayer?.removeFromSuperlayer()
|
|
headingLayer = nil
|
|
addHeadingIndicator(heading: heading)
|
|
}
|
|
|
|
private func addHeadingIndicator(heading: Double) {
|
|
headingLayer?.removeFromSuperlayer()
|
|
|
|
let center = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
|
|
let radius: CGFloat = 80
|
|
|
|
let halfFan = 30.0 * .pi / 180
|
|
let centerAngle = CGFloat(heading) * .pi / 180 - .pi / 2
|
|
let startAngle = centerAngle - halfFan
|
|
let endAngle = centerAngle + halfFan
|
|
|
|
let path = UIBezierPath()
|
|
path.move(to: center)
|
|
path.addArc(withCenter: center, radius: radius,
|
|
startAngle: startAngle, endAngle: endAngle,
|
|
clockwise: true)
|
|
path.close()
|
|
|
|
let shapeLayer = CAShapeLayer()
|
|
shapeLayer.frame = containerView.bounds
|
|
shapeLayer.path = path.cgPath
|
|
shapeLayer.fillColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.25).cgColor
|
|
shapeLayer.strokeColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.4).cgColor
|
|
shapeLayer.lineWidth = 1
|
|
containerView.layer.insertSublayer(shapeLayer, at: 0)
|
|
headingLayer = shapeLayer
|
|
}
|
|
|
|
// MARK: - Pulse Animation
|
|
private func startPulseAnimation() {
|
|
guard pulseLayer == nil else { return }
|
|
|
|
let layer = CAShapeLayer()
|
|
layer.fillColor = UIColor.clear.cgColor
|
|
layer.strokeColor = UIColor(hexStr: "#16B3FF").withAlphaComponent(0.5).cgColor
|
|
layer.lineWidth = 2
|
|
layer.frame = bounds
|
|
containerView.layer.insertSublayer(layer, at: 0)
|
|
pulseLayer = layer
|
|
|
|
let center = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
|
|
let radius = Self.avatarOuterSize / 2 + 8
|
|
let ringRect = CGRect(x: center.x - radius, y: center.y - radius,
|
|
width: radius * 2, height: radius * 2)
|
|
layer.path = UIBezierPath(ovalIn: ringRect).cgPath
|
|
|
|
let scale = CABasicAnimation(keyPath: "transform.scale")
|
|
scale.fromValue = 0.9
|
|
scale.toValue = 1.5
|
|
|
|
let opacity = CABasicAnimation(keyPath: "opacity")
|
|
opacity.fromValue = 0.8
|
|
opacity.toValue = 0.0
|
|
|
|
let group = CAAnimationGroup()
|
|
group.animations = [scale, opacity]
|
|
group.duration = 1.6
|
|
group.repeatCount = .infinity
|
|
group.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
|
|
layer.add(group, forKey: "pulse")
|
|
}
|
|
|
|
private func stopPulseAnimation() {
|
|
pulseLayer?.removeAllAnimations()
|
|
pulseLayer?.removeFromSuperlayer()
|
|
pulseLayer = nil
|
|
}
|
|
}
|
|
#endif
|