jsdw_ios/QuickLocation/Section/Group/GroupMemberList/ItineraryTraceVC.swift

745 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ItineraryTraceVC.swift
// QuickLocation
//
// Created by on 2026/6/30.
//
import UIKit
import RxSwift
import RxCocoa
import ObjectMapper
import SwiftyUserDefaults
import AMapNaviKit
import AMapSearchKit
class ItineraryTraceVC: BaseViewController {
fileprivate var rootView: ItineraryTraceView!
private let model: ScheduleRecordModel
private let memberModel: GroupMemberModel
private var routeOverlays: [MAPolyline] = []
private let routeSearch = AMapSearchAPI()
private var playbackSegments: [[CLLocationCoordinate2D]?] = []
private var playbackPath: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
private var playbackTotalDistance: Double = 0
private var playbackAnnotation: TracePlaybackAnnotation?
private var displayLink: CADisplayLink?
private var playbackProgress: Double = 0
private var playbackLastTick: Date?
private var playbackLastCenterUpdate: Date?
private var playbackDuration: TimeInterval = 30
private let playbackFollowInterval: TimeInterval = 0.2
private let minPlaybackDuration: TimeInterval = 10
private let maxPlaybackDuration: TimeInterval = 180
private let playbackReplaySpeed: CLLocationDistance = 80
private let playbackPathMinStep: CLLocationDistance = 5
private let playbackInterpolationSteps = 3
private var playbackInterpolationFrame = 0
private var playbackPrevCoordinate: CLLocationCoordinate2D?
private var playbackTargetCoordinate: CLLocationCoordinate2D?
private let minPointDistance: CLLocationDistance = 10
private let eventCalloutTag = 91301
override func loadView() {
rootView = ItineraryTraceView(frame: UIScreen.main.bounds)
view = rootView
}
init(scheduleJson: [String: Any], memberInfo: [String: Any]) {
self.model = ScheduleRecordModel(JSON: scheduleJson) ?? ScheduleRecordModel(JSON: ["id": ""])!
self.memberModel = GroupMemberModel(JSON: memberInfo) ?? GroupMemberModel(JSON: ["id": ""])!
print(self.model)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
rootView.eventCollectionView.dataSource = self
rootView.eventCollectionView.delegate = self
setupMap()
populateData()
addAnnotations()
drawRoute()
setupPlaybackControls()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed {
stopPlayback(resetProgress: false)
rootView.cleanupMap()
}
}
@MainActor deinit {
displayLink?.invalidate()
}
private func populateData() {
rootView.titleLab.text = "\(memberModel.nick_name) 的驾驶详细信息"
rootView.startTimeLab.text = model.start_time.isoStringToCustom(model.start_time, format: "HH:mm")
rootView.endTimeLab.text = model.end_time.isoStringToCustom(model.end_time, format: "HH:mm")
rootView.startAddressLab.text = model.start_address?.street
rootView.endAddressLab.text = model.end_address?.street
}
// MARK: - Map
private func setupMap() {
rootView.mapView.delegate = self
routeSearch?.delegate = self
if let lat = Defaults[\.currentLatitude], let lon = Defaults[\.currentLongitude] {
let coord = CLLocationCoordinate2D(latitude: lat, longitude: lon)
if CLLocationCoordinate2DIsValid(coord) {
rootView.mapView.setCenter(coord, animated: false)
rootView.mapView.setZoomLevel(14, animated: false)
}
}
}
private func addAnnotations() {
//
if let start = model.trajectory_path.first, abs(start.lat) > 0.0001 {
let ann = MAPointAnnotation()
ann.coordinate = CLLocationCoordinate2D(latitude: start.lat, longitude: start.lng)
ann.title = "start"
rootView.mapView.addAnnotation(ann)
}
//
if let end = model.trajectory_path.last, abs(end.lat) > 0.0001 {
let ann = MAPointAnnotation()
ann.coordinate = CLLocationCoordinate2D(latitude: end.lat, longitude: end.lng)
ann.title = "end"
rootView.mapView.addAnnotation(ann)
}
// driving_events
for ev in model.driving_events {
guard abs(ev.latitude) > 0.0001 else { continue }
let ann = TraceEventAnnotation()
ann.coordinate = CLLocationCoordinate2D(latitude: ev.latitude, longitude: ev.longitude)
ann.title = "event"
ann.eventType = ev.event_type
ann.eventTime = eventTimeText(ev.timestamp)
ann.eventAddress = ev.address?.street ?? ""
rootView.mapView.addAnnotation(ann)
}
//
if let anns = rootView.mapView.annotations, anns.count > 0 {
rootView.mapView.showAnnotations(anns, animated: true)
}
}
private func drawRoute() {
clearRouteOverlays()
let points = cleanedTracePoints(model.trajectory_path)
guard points.count >= 2 else { print("[trace] insufficient points: \(points.count)"); return }
playbackSegments = []
var sampled: [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] = []
for pt in points {
if let last = sampled.last {
if pt.timestamp - last.timestamp >= 5_000 {
sampled.append(pt)
}
} else {
sampled.append(pt)
}
}
let coords = sampled.map { $0.coordinate }
addPolyline(coords)
playbackSegments = [coords]
buildPlaybackPath()
updatePlaybackAnnotation(progress: playbackProgress)
// let middle = sampled.dropFirst().dropLast().prefix(16)
// let waypoints = middle.map { $0.coordinate }
//
// let request = AMapDrivingRouteSearchRequest()
// request.origin = AMapGeoPoint.location(withLatitude: CGFloat(points.first!.coordinate.latitude), longitude: CGFloat(points.first!.coordinate.longitude))
// request.destination = AMapGeoPoint.location(withLatitude: CGFloat(points.last!.coordinate.latitude), longitude: CGFloat(points.last!.coordinate.longitude))
// request.strategy = 0
// if !waypoints.isEmpty {
// request.waypoints = waypoints.map { AMapGeoPoint.location(withLatitude: CGFloat($0.latitude), longitude: CGFloat($0.longitude)) }
// }
// routeSearch?.aMapDrivingRouteSearch(request)
print("[trace] cleaned=\(points.count) sampled=\(sampled.count)")
}
private func buildPlaybackPath() {
var path: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
var cumulative: Double = 0
var lastCoord: CLLocationCoordinate2D?
for segment in playbackSegments.compactMap({ $0 }) where segment.count > 1 {
for coord in segment {
if let last = lastCoord {
let d = last.distance(to: coord)
if d < playbackPathMinStep { continue }
cumulative += d
}
path.append((coordinate: coord, distance: cumulative))
lastCoord = coord
}
}
playbackPath = path
playbackTotalDistance = cumulative
}
private func clearRouteOverlays() {
for overlay in routeOverlays {
rootView.mapView.remove(overlay)
}
routeOverlays.removeAll()
}
private func cleanedTracePoints(_ points: [TrackPoint]) -> [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] {
let sorted = points.sorted { $0.timestamp < $1.timestamp }
var result: [(timestamp: Int64, coordinate: CLLocationCoordinate2D)] = []
for point in sorted {
let coordinate = CLLocationCoordinate2D(latitude: point.lat, longitude: point.lng)
guard abs(point.lat) > 0.0001,
abs(point.lng) > 0.0001,
CLLocationCoordinate2DIsValid(coordinate) else { continue }
if let last = result.last,
last.coordinate.distance(to: coordinate) < minPointDistance {
continue
}
result.append((timestamp: point.timestamp, coordinate: coordinate))
}
return result
}
private func setupPlaybackControls() {
rootView.playBtn.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchDown), for: .touchDown)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderValueChanged), for: .valueChanged)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpInside)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchUpOutside)
rootView.progressSlider.addTarget(self, action: #selector(progressSliderTouchEnded), for: .touchCancel)
}
@objc private func playButtonTapped() {
if !rootView.playBtn.isSelected, playbackProgress >= 1 {
playbackProgress = 0
rootView.progressSlider.value = 0
updatePlaybackAnnotation(progress: playbackProgress)
}
rootView.playBtn.isSelected ? pausePlayback() : startPlayback()
}
@objc private func progressSliderTouchDown() {
pausePlayback()
}
@objc private func progressSliderValueChanged() {
playbackProgress = Double(rootView.progressSlider.value)
updatePlaybackAnnotation(progress: playbackProgress)
}
@objc private func progressSliderTouchEnded() {
playbackProgress = Double(rootView.progressSlider.value)
updatePlaybackAnnotation(progress: playbackProgress)
}
private func startPlayback() {
let totalDistance = playbackPathDistance()
guard totalDistance > 0 else { return }
guard playbackProgress < 1 else { return }
playbackDuration = max(minPlaybackDuration, min(maxPlaybackDuration, totalDistance / playbackReplaySpeed))
rootView.playBtn.isSelected = true
playbackLastTick = Date()
playbackLastCenterUpdate = nil
playbackInterpolationFrame = playbackInterpolationSteps
if rootView.mapView.zoomLevel < 16 {
rootView.mapView.setZoomLevel(16, animated: false)
}
displayLink?.invalidate()
displayLink = CADisplayLink(target: self, selector: #selector(handleDisplayLink))
displayLink?.add(to: .main, forMode: .common)
}
private func pausePlayback() {
rootView.playBtn.isSelected = false
displayLink?.invalidate()
displayLink = nil
playbackLastTick = nil
playbackLastCenterUpdate = nil
}
private func stopPlayback(resetProgress: Bool) {
pausePlayback()
if resetProgress {
playbackProgress = 0
rootView.progressSlider.value = 0
updatePlaybackAnnotation(progress: playbackProgress)
}
}
@objc private func handleDisplayLink() {
let now = Date()
let delta = playbackLastTick.map { now.timeIntervalSince($0) } ?? 0
playbackLastTick = now
playbackProgress = min(1, playbackProgress + delta / playbackDuration)
rootView.progressSlider.value = Float(playbackProgress)
playbackInterpolationFrame += 1
if playbackInterpolationFrame >= playbackInterpolationSteps {
playbackInterpolationFrame = 0
playbackPrevCoordinate = playbackTargetCoordinate
playbackTargetCoordinate = playbackCoordinate(at: playbackProgress)
}
if let target = playbackTargetCoordinate {
if let prev = playbackPrevCoordinate {
let t = Double(playbackInterpolationFrame) / Double(playbackInterpolationSteps)
let coord = prev.interpolate(to: target, ratio: t)
setPlaybackAnnotationCoordinate(coord)
followPlaybackCoordinateIfNeeded(coord)
} else {
setPlaybackAnnotationCoordinate(target)
followPlaybackCoordinateIfNeeded(target)
}
}
if playbackProgress >= 1 {
finishPlayback()
}
}
private func finishPlayback() {
playbackProgress = 1
rootView.progressSlider.value = 1
updatePlaybackAnnotation(progress: playbackProgress)
pausePlayback()
}
private func updatePlaybackAnnotation(progress: Double, interpolated: Bool = false) {
guard let coordinate = playbackCoordinate(at: progress) else { return }
if !interpolated {
playbackPrevCoordinate = coordinate
playbackTargetCoordinate = coordinate
playbackInterpolationFrame = playbackInterpolationSteps
}
setPlaybackAnnotationCoordinate(coordinate)
followPlaybackCoordinateIfNeeded(coordinate)
}
private func setPlaybackAnnotationCoordinate(_ coordinate: CLLocationCoordinate2D) {
if let annotation = playbackAnnotation {
annotation.coordinate = coordinate
} else {
let annotation = TracePlaybackAnnotation()
annotation.coordinate = coordinate
playbackAnnotation = annotation
rootView.mapView.addAnnotation(annotation)
}
}
private func followPlaybackCoordinateIfNeeded(_ coordinate: CLLocationCoordinate2D) {
guard rootView.playBtn.isSelected else { return }
let now = Date()
if let lastUpdate = playbackLastCenterUpdate,
now.timeIntervalSince(lastUpdate) < playbackFollowInterval {
return
}
playbackLastCenterUpdate = now
rootView.mapView.setCenter(coordinate, animated: true)
}
private func playbackCoordinate(at progress: Double) -> CLLocationCoordinate2D? {
let clampedProgress = min(max(progress, 0), 1)
let path = playbackPath
guard !path.isEmpty, playbackTotalDistance > 0 else { return path.first?.coordinate }
let targetDistance = playbackTotalDistance * clampedProgress
var lo = 0, hi = path.count - 1
while lo < hi {
let mid = (lo + hi) / 2
if path[mid].distance < targetDistance {
lo = mid + 1
} else {
hi = mid
}
}
let idx = lo
let prev = idx > 0 ? path[idx - 1] : path[0]
let curr = path[idx]
let segDist = curr.distance - prev.distance
let ratio = segDist > 0 ? max(0, min(1, (targetDistance - prev.distance) / segDist)) : 0
return prev.coordinate.interpolate(to: curr.coordinate, ratio: ratio)
}
private func playbackPathDistance() -> CLLocationDistance {
playbackTotalDistance
}
@discardableResult
private func addPolyline(_ coordinates: [CLLocationCoordinate2D]) -> MAPolyline? {
guard coordinates.count > 1 else { return nil }
var mutableCoords = coordinates
guard let polyline = MAPolyline(coordinates: &mutableCoords, count: UInt(mutableCoords.count)) else { return nil }
rootView.mapView.add(polyline)
routeOverlays.append(polyline)
return polyline
}
private func coordinates(from path: AMapPath) -> [CLLocationCoordinate2D] {
var coords: [CLLocationCoordinate2D] = []
for step in path.steps {
guard let polylineStr = step.polyline else { continue }
for point in polylineStr.components(separatedBy: ";") {
let latLon = point.components(separatedBy: ",")
if latLon.count == 2,
let lon = Double(latLon[0]),
let lat = Double(latLon[1]) {
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
if CLLocationCoordinate2DIsValid(coordinate) {
coords.append(coordinate)
}
}
}
}
return coords
}
private func eventTimeText(_ timestamp: Int64) -> String {
let seconds = timestamp > 10_000_000_000 ? Double(timestamp) / 1000.0 : Double(timestamp)
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter.string(from: Date(timeIntervalSince1970: seconds))
}
private static func playbackAvatarImage(_ image: UIImage) -> UIImage? {
let size = CGSize(width: 34, height: 34)
UIGraphicsBeginImageContextWithOptions(size, false, 0)
defer { UIGraphicsEndImageContext() }
let rect = CGRect(origin: .zero, size: size)
UIColor.white.setFill()
UIBezierPath(ovalIn: rect).fill()
let imageRect = rect.insetBy(dx: 3, dy: 3)
let clipPath = UIBezierPath(ovalIn: imageRect)
clipPath.addClip()
image.draw(in: imageRect)
return UIGraphicsGetImageFromCurrentImageContext()
}
}
private extension CLLocationCoordinate2D {
func distance(to coordinate: CLLocationCoordinate2D) -> CLLocationDistance {
let fromLocation = CLLocation(latitude: latitude, longitude: longitude)
let toLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
return fromLocation.distance(from: toLocation)
}
func interpolate(to coordinate: CLLocationCoordinate2D, ratio: Double) -> CLLocationCoordinate2D {
let clampedRatio = min(max(ratio, 0), 1)
return CLLocationCoordinate2D(latitude: latitude + (coordinate.latitude - latitude) * clampedRatio,
longitude: longitude + (coordinate.longitude - longitude) * clampedRatio)
}
}
private extension Array where Element == CLLocationCoordinate2D {
var totalDistance: CLLocationDistance {
guard count > 1 else { return 0 }
var distance: CLLocationDistance = 0
for i in 0..<(count - 1) {
distance += self[i].distance(to: self[i + 1])
}
return distance
}
}
private final class TracePlaybackAnnotation: MAPointAnnotation {}
// MARK: - MAMapViewDelegate
extension ItineraryTraceVC: MAMapViewDelegate {
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
guard !(annotation is MAUserLocation) else { return nil }
guard let pointAnn = annotation as? MAPointAnnotation else { return nil }
if annotation is TracePlaybackAnnotation {
let id = "PlaybackUserPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.image = Self.playbackAvatarImage(memberModel.userIcon)
view?.centerOffset = CGPoint(x: 0, y: -16)
return view
}
if pointAnn.title == "start" {
let id = "StartPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.image = UIImage(named: "ItineraryTrace/start")
view?.centerOffset = CGPoint(x: 0, y: -12)
return view
}
if pointAnn.title == "end" {
let id = "EndPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.image = UIImage(named: "ItineraryTrace/end")
view?.centerOffset = CGPoint(x: 0, y: -12)
return view
}
if pointAnn.title == "event" {
let id = "EventPin"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil { view = MAAnnotationView(annotation: annotation, reuseIdentifier: id) }
else { view?.annotation = annotation }
view?.viewWithTag(eventCalloutTag)?.removeFromSuperview()
view?.image = UIImage(named: "ItineraryTrace/event")
view?.bounds.size = CGSize(width: 20, height: 20)
view?.clipsToBounds = false
return view
}
return nil
}
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
guard let annotation = view.annotation as? TraceEventAnnotation else { return }
view.viewWithTag(eventCalloutTag)?.removeFromSuperview()
let calloutWidth = TraceEventCalloutView.preferredWidth(type: annotation.eventType,
time: annotation.eventTime,
address: annotation.eventAddress)
let callout = TraceEventCalloutView(frame: CGRect(x: 0, y: 0, width: calloutWidth, height: 72))
callout.tag = eventCalloutTag
callout.configure(icon: annotation.eventTypeIcon,
type: annotation.eventTypeText,
time: annotation.eventTime,
address: annotation.eventAddress)
callout.frame.origin = CGPoint(x: (view.bounds.width - callout.bounds.width) / 2, y: -callout.bounds.height - 8)
view.addSubview(callout)
}
func mapView(_ mapView: MAMapView!, didDeselect view: MAAnnotationView!) {
view.viewWithTag(eventCalloutTag)?.removeFromSuperview()
}
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
if let polyline = overlay as? MAPolyline {
let r = MAPolylineRenderer(polyline: polyline)
r?.strokeColor = UIColor(hexStr: "#16B3FF")
r?.lineWidth = 3
r?.lineJoinType = kMALineJoinRound
r?.lineCapType = kMALineCapRound
return r
}
return nil
}
}
// MARK: - AMapSearchDelegate
extension ItineraryTraceVC: AMapSearchDelegate {
func onRouteSearchDone(_ request: AMapRouteSearchBaseRequest!, response: AMapRouteSearchResponse!) {
guard let path = response.route?.paths?.first as? AMapPath else { return }
let coords = coordinates(from: path)
guard coords.count > 1 else { return }
addPolyline(coords)
playbackSegments = [coords]
buildPlaybackPath()
updatePlaybackAnnotation(progress: playbackProgress)
}
func aMapSearchRequest(_ request: Any!, didFailWithError error: Error!) {
print("Trace route search error: \(error.localizedDescription)")
}
}
// MARK: - UICollectionViewDataSource & Delegate ()
extension ItineraryTraceVC: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 8
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell: ItineraryTraceCell = collectionView.dequeueReusableCell(for: indexPath)
guard let stats = model.statistics else { return cell }
let all: [(String, String, Int)] = [
("急加速", "GroupMemberList/1", stats.hard_acceleration),
("急转向", "GroupMemberList/3", stats.sharp_turn),
("急刹", "GroupMemberList/5", stats.hard_braking),
("手机干扰", "GroupMemberList/7", stats.signal_loss),
("超速", "GroupMemberList/2", stats.speeding),
("低速", "GroupMemberList/4", stats.low_speeding),
("频繁变道", "GroupMemberList/6", stats.frequent_lane_change),
("长时间驾驶", "GroupMemberList/8", stats.long_driving)
]
let item = all[indexPath.row]
cell.iconView.image = UIImage(named: item.1)
cell.nameLab.text = item.0
cell.countLab.text = "\(item.2)"
return cell
}
}
// MARK: -
private final class TraceEventAnnotation: MAPointAnnotation {
var eventType = ""
var eventTypeText: String {
get {
switch eventType {
case "hard_acceleration":
return "急加速"
case "sharp_turn":
return "急转向"
case "hard_braking":
return "急刹"
case "signal_loss":
return "手机干扰"
case "speeding":
return "超速"
case "low_speeding":
return "低速"
case "frequent_lane_change":
return "频繁变道"
case "long_driving":
return "长时间驾驶"
default:
return eventType
}
}
}
var eventTypeIcon: UIImage? {
get {
switch eventType {
case "hard_acceleration":
return UIImage(named: "GroupMemberList/1")
case "sharp_turn":
return UIImage(named: "GroupMemberList/3")
case "hard_braking":
return UIImage(named: "GroupMemberList/5")
case "signal_loss":
return UIImage(named: "GroupMemberList/7")
case "speeding":
return UIImage(named: "GroupMemberList/2")
case "low_speeding":
return UIImage(named: "GroupMemberList/4")
case "frequent_lane_change":
return UIImage(named: "GroupMemberList/6")
case "long_driving":
return UIImage(named: "GroupMemberList/8")
default:
return nil
}
}
}
var eventTime = ""
var eventAddress = ""
}
private final class TraceEventCalloutView: UIView {
private let bubbleView = UIView()
private let arrowView = UIView()
private let typeIcon: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFill
return view
}()
private let typeLab: UILabel = {
let lab = UILabel()
lab.font = .systemFont(ofSize: 14, weight: .medium)
lab.textColor = UIColor(hexStr: "#333333")
lab.numberOfLines = 1
return lab
}()
private let detailLab: UILabel = {
let lab = UILabel()
lab.font = .systemFont(ofSize: 12)
lab.textColor = UIColor(hexStr: "#666666")
lab.numberOfLines = 1
return lab
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
bubbleView.backgroundColor = .white
bubbleView.layer.cornerRadius = 8
bubbleView.layer.shadowColor = UIColor.black.withAlphaComponent(0.16).cgColor
bubbleView.layer.shadowOpacity = 1
bubbleView.layer.shadowOffset = CGSize(width: 0, height: 2)
bubbleView.layer.shadowRadius = 8
arrowView.backgroundColor = .white
arrowView.transform = CGAffineTransform(rotationAngle: .pi / 4)
addSubview(bubbleView)
addSubview(arrowView)
bubbleView.addSubview(typeIcon)
bubbleView.addSubview(typeLab)
bubbleView.addSubview(detailLab)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let arrowSize: CGFloat = 12
bubbleView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: bounds.height - arrowSize)
arrowView.bounds = CGRect(x: 0, y: 0, width: arrowSize, height: arrowSize)
arrowView.center = CGPoint(x: bounds.midX, y: bubbleView.frame.maxY - 1)
typeIcon.frame = CGRect(x: 9, y: 12, width: 16, height: 16)
typeLab.frame = CGRect(x: 28, y: 10, width: bubbleView.bounds.width - 24, height: 20)
detailLab.frame = CGRect(x: 9, y: 34, width: bubbleView.bounds.width - 24, height: 18)
}
func configure(icon: UIImage?, type: String, time: String, address: String) {
if let img = icon {
typeIcon.image = img
}
typeLab.text = type
let detail = address.isEmpty ? time : "\(time)\(address)"
let attr = NSMutableAttributedString(string: detail)
attr.addAttributes([
.foregroundColor: UIColor(hexStr: "#16B3FF"),
.font: UIFont.systemFont(ofSize: 12, weight: .medium)
], range: NSRange(location: 0, length: min(time.count, detail.count)))
detailLab.attributedText = attr
}
static func preferredWidth(type: String, time: String, address: String) -> CGFloat {
let title = type.isEmpty ? "驾驶事件" : type
let detail = address.isEmpty ? time : "\(time)\(address)"
let titleWidth = (title as NSString).size(withAttributes: [.font: UIFont.systemFont(ofSize: 14, weight: .medium)]).width
let detailWidth = (detail as NSString).size(withAttributes: [.font: UIFont.systemFont(ofSize: 12)]).width + 5
return min(max(max(titleWidth, detailWidth) + 24, 120), 280)
}
}