jsdw_ios/QuickLocation/Section/Home/TodayTrackDetail/TodayTrackDetailVC.swift

730 lines
28 KiB
Swift

//
// TodayTrackDetailVC.swift
// QuickLocation
//
import UIKit
import RxSwift
import RxCocoa
import AMapNaviKit
import SwiftDate
import ObjectMapper
import CoreLocation
final class TodayTrackDetailVC: BaseViewController {
private var rootView: TodayTrackDetailView!
private let viewModel: TodayTrackDetailViewModel
private var routeOverlays: [MAPolyline] = []
private var panStartHeight: CGFloat = 0
private var tripList: [ScheduleRecordModel] = []
private var didScrollDatesToEnd = false
private var lastDateCVWidth: CGFloat = 0
private var suppressMapTapHide = false
private var playbackPath: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
private var playbackTotalDistance: Double = 0
private var playbackAnnotation: HistoryTrackPlaybackAnnotation?
private var displayLink: CADisplayLink?
private var playbackProgress: Double = 0
private var playbackLastTick: Date?
private var playbackDuration: TimeInterval = 30
private let minPlaybackDuration: TimeInterval = 10
private let maxPlaybackDuration: TimeInterval = 180
private let playbackReplaySpeed: CLLocationDistance = 80
init(members: [GroupMemberModel], selectedUserId: String) {
self.viewModel = TodayTrackDetailViewModel(members: members, selectedUserId: selectedUserId)
super.init(nibName: nil, bundle: nil)
}
convenience init(userInfo: [String: Any]) {
let rawMembers = userInfo["members"]
let membersJson: [[String: Any]]
if let arr = rawMembers as? [[String: Any]] {
membersJson = arr
} else if let arr = rawMembers as? [Any] {
membersJson = arr.compactMap { $0 as? [String: Any] }
} else {
membersJson = []
}
let members = membersJson.compactMap { GroupMemberModel(JSON: $0) }
let selected = (userInfo["userId"] as? String) ?? members.first?.user_id ?? ""
self.init(members: members, selectedUserId: selected)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
@MainActor deinit {
displayLink?.invalidate()
}
override func loadView() {
rootView = TodayTrackDetailView(frame: UIScreen.main.bounds)
view = rootView
}
override func viewDidLoad() {
super.viewDidLoad()
rootView.mapView.delegate = self
rootView.memberCV.dataSource = self
rootView.memberCV.delegate = self
rootView.dateCV.dataSource = self
rootView.dateCV.delegate = self
rootView.timelineTV.dataSource = self
rootView.timelineTV.delegate = self
bindActions()
bindSheetPan()
setupPlaybackControls()
rootView.memberCV.reloadData()
rootView.dateCV.reloadData()
scrollToSelectedMember(animated: false)
DispatchQueue.main.async { [weak self] in
self?.rootView.scrollDatesToEnd(animated: false)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let width = rootView.dateCV.bounds.width
guard width > 0, abs(width - lastDateCVWidth) > 0.5 else { return }
lastDateCVWidth = width
rootView.dateCV.collectionViewLayout.invalidateLayout()
rootView.dateCV.reloadData()
if !didScrollDatesToEnd {
didScrollDatesToEnd = true
rootView.scrollDatesToEnd(animated: false)
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed {
DLToast.dismiss()
stopPlayback(resetProgress: false)
rootView.cleanupMap()
}
}
private func bindActions() {
rootView.backBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.navigationController?.popViewController(animated: true)
})
.disposed(by: disposeBag)
rootView.datePrevBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.rootView.scrollDates(direction: -1)
})
.disposed(by: disposeBag)
rootView.dateNextBtn.rx.tap
.subscribe(onNext: { [weak self] in
self?.rootView.scrollDates(direction: 1)
})
.disposed(by: disposeBag)
rootView.vipRequiredBtn.rx.tap
.subscribe(onNext: {
AppRouter.push(Route.vipRecharge)
})
.disposed(by: disposeBag)
viewModel.displayedTrips
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] trips in
self?.drawRoute(trips)
})
.disposed(by: disposeBag)
viewModel.isEmpty
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] empty in
guard let self, !self.viewModel.isVipRequired.value else { return }
self.rootView.emptyLab.isHidden = !empty
})
.disposed(by: disposeBag)
viewModel.isVipRequired
.observe(on: MainScheduler.instance)
.distinctUntilChanged()
.subscribe(onNext: { [weak self] required in
guard let self else { return }
self.stopPlayback(resetProgress: true)
self.rootView.setVipRequired(required)
self.updateSheetHeight(isVipRequired: required)
if !required {
self.rootView.emptyLab.isHidden = !self.viewModel.isEmpty.value
}
})
.disposed(by: disposeBag)
viewModel.trips
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] trips in
self?.tripList = trips
self?.rootView.timelineTV.reloadData()
})
.disposed(by: disposeBag)
viewModel.selectedMemberId
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
self?.rootView.memberCV.reloadData()
self?.scrollToSelectedMember(animated: true)
})
.disposed(by: disposeBag)
viewModel.selectedDateKey
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
self?.rootView.dateCV.reloadData()
})
.disposed(by: disposeBag)
viewModel.loading
.observe(on: MainScheduler.instance)
.distinctUntilChanged()
.subscribe(onNext: { loading in
if loading {
DLToast.showLoading()
} else {
DLToast.dismiss()
}
})
.disposed(by: disposeBag)
}
private func updateSheetHeight(isVipRequired: Bool) {
guard let constraint = rootView.sheetHeightConstraint else { return }
let minHeight: CGFloat = 200 + kSafeBottomMargin
let maxHeight: CGFloat = UIScreen.main.bounds.height * 0.7
let vipHeight: CGFloat = UIScreen.main.bounds.height * 0.47
constraint.constant = isVipRequired ? min(maxHeight, max(minHeight, vipHeight)) : minHeight
}
private func bindSheetPan() {
let pan = UIPanGestureRecognizer()
rootView.sheetView.addGestureRecognizer(pan)
pan.rx.event.subscribe(onNext: { [weak self] gesture in
guard let self, let constraint = self.rootView.sheetHeightConstraint else { return }
let minH: CGFloat = 200 + kSafeBottomMargin
let maxH: CGFloat = UIScreen.main.bounds.height * 0.7
switch gesture.state {
case .began:
self.panStartHeight = constraint.constant
case .changed:
let dy = gesture.translation(in: self.rootView).y
constraint.constant = min(maxH, max(minH, self.panStartHeight - dy))
case .ended, .cancelled:
let mid = (minH + maxH) / 2
let target = constraint.constant > mid ? maxH : minH
UIView.animate(withDuration: 0.25) {
constraint.constant = target
self.rootView.layoutIfNeeded()
}
default:
break
}
}).disposed(by: disposeBag)
}
private func collapseSheetIfExpanded() {
guard let constraint = rootView.sheetHeightConstraint else { return }
let minH: CGFloat = 200 + kSafeBottomMargin
let maxH: CGFloat = UIScreen.main.bounds.height * 0.7
let mid = (minH + maxH) / 2
guard constraint.constant > mid else { return }
UIView.animate(withDuration: 0.25) {
constraint.constant = minH
self.rootView.layoutIfNeeded()
}
}
private func scrollToSelectedMember(animated: Bool) {
guard let idx = viewModel.members.firstIndex(where: { $0.user_id == viewModel.selectedMemberId.value }),
viewModel.members.count > 0 else { return }
let indexPath = IndexPath(item: idx, section: 0)
guard indexPath.item < rootView.memberCV.numberOfItems(inSection: 0) else { return }
rootView.memberCV.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: animated)
}
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)
}
private func drawRoute(_ trips: [ScheduleRecordModel]) {
stopPlayback(resetProgress: true)
rootView.mapView.removeOverlays(routeOverlays)
routeOverlays.removeAll()
if let anns = rootView.mapView.annotations {
rootView.mapView.removeAnnotations(anns)
}
playbackAnnotation = nil
playbackPath = []
playbackTotalDistance = 0
let points = trips.flatMap { $0.trajectory_path }
let coords = points.compactMap { point -> CLLocationCoordinate2D? in
let coord = CLLocationCoordinate2D(latitude: point.lat, longitude: point.lng)
guard abs(point.lat) > 0.0001, abs(point.lng) > 0.0001, CLLocationCoordinate2DIsValid(coord) else {
return nil
}
return coord
}
buildPlaybackPath(coords)
if coords.count >= 2 {
var mutable = coords
if let polyline = MAPolyline(coordinates: &mutable, count: UInt(mutable.count)) {
rootView.mapView.add(polyline)
routeOverlays.append(polyline)
let padding = UIEdgeInsets(top: 170, left: 40, bottom: 420, right: 40)
rootView.mapView.showOverlays(routeOverlays, edgePadding: padding, animated: true)
}
} else if let first = coords.first {
rootView.mapView.setCenter(first, animated: true)
}
if let start = coords.first {
let ann = MAPointAnnotation()
ann.coordinate = start
ann.title = "start"
rootView.mapView.addAnnotation(ann)
}
if let end = coords.last {
let ann = MAPointAnnotation()
ann.coordinate = end
ann.title = "end"
rootView.mapView.addAnnotation(ann)
}
for stay in trips.flatMap({ $0.stay_points }) {
let coord = CLLocationCoordinate2D(latitude: stay.lat, longitude: stay.lng)
guard abs(stay.lat) > 0.0001, abs(stay.lng) > 0.0001, CLLocationCoordinate2DIsValid(coord) else {
continue
}
let ann = HistoryTrackStayAnnotation()
ann.coordinate = coord
ann.title = "stay"
ann.minutes = max(0, stay.duration_minutes)
rootView.mapView.addAnnotation(ann)
}
updatePlaybackAnnotation(progress: 0)
}
private func showStayDuration(at coordinate: CLLocationCoordinate2D, minutes: Int) {
hideStayDuration()
let ann = HistoryTrackDurationAnnotation()
ann.coordinate = coordinate
ann.title = "duration"
ann.text = Self.durationBubbleText(minutes)
rootView.mapView.addAnnotation(ann)
}
private func hideStayDuration() {
let durations = (rootView.mapView.annotations ?? []).compactMap { $0 as? HistoryTrackDurationAnnotation }
if !durations.isEmpty {
rootView.mapView.removeAnnotations(durations)
}
}
private func buildPlaybackPath(_ coords: [CLLocationCoordinate2D]) {
var path: [(coordinate: CLLocationCoordinate2D, distance: Double)] = []
var cumulative: Double = 0
var last: CLLocationCoordinate2D?
for coord in coords {
if let last {
cumulative += last.distance(to: coord)
}
path.append((coordinate: coord, distance: cumulative))
last = coord
}
playbackPath = path
playbackTotalDistance = cumulative
}
// MARK: - Playback
@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() {
guard playbackPath.count >= 2, playbackProgress < 1 else { return }
let distance = max(playbackTotalDistance, 1)
playbackDuration = max(minPlaybackDuration, min(maxPlaybackDuration, distance / playbackReplaySpeed))
rootView.playBtn.isSelected = true
playbackLastTick = Date()
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
}
private func stopPlayback(resetProgress: Bool) {
pausePlayback()
if resetProgress {
playbackProgress = 0
rootView.progressSlider.value = 0
}
}
@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)
updatePlaybackAnnotation(progress: playbackProgress)
if playbackProgress >= 1 {
pausePlayback()
}
}
private func updatePlaybackAnnotation(progress: Double) {
guard let coordinate = playbackCoordinate(at: progress) else { return }
if let annotation = playbackAnnotation {
annotation.coordinate = coordinate
} else {
let annotation = HistoryTrackPlaybackAnnotation()
annotation.coordinate = coordinate
playbackAnnotation = annotation
rootView.mapView.addAnnotation(annotation)
}
}
private func playbackCoordinate(at progress: Double) -> CLLocationCoordinate2D? {
let clamped = min(max(progress, 0), 1)
let path = playbackPath
guard !path.isEmpty else { return nil }
guard playbackTotalDistance > 0 else { return path.first?.coordinate }
let target = playbackTotalDistance * clamped
var lo = 0, hi = path.count - 1
while lo < hi {
let mid = (lo + hi) / 2
if path[mid].distance < target {
lo = mid + 1
} else {
hi = mid
}
}
let idx = lo
let prev = idx > 0 ? path[idx - 1] : path[0]
let curr = path[idx]
let seg = curr.distance - prev.distance
let ratio = seg > 0 ? max(0, min(1, (target - prev.distance) / seg)) : 0
return prev.coordinate.interpolate(to: curr.coordinate, ratio: ratio)
}
private static func stayPointImage() -> UIImage {
let size = CGSize(width: 14, height: 14)
UIGraphicsBeginImageContextWithOptions(size, false, 0)
defer { UIGraphicsEndImageContext() }
UIColor(hexStr: "#293445").setFill()
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
UIColor.white.setFill()
UIBezierPath(ovalIn: CGRect(x: 4, y: 4, width: 6, height: 6)).fill()
return UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
}
private static func durationBubbleText(_ minutes: Int) -> String {
let hours = minutes / 60
let mins = minutes % 60
if hours > 0 {
return "\(hours)小时\(mins)"
}
return "\(mins)分钟"
}
private static func playbackAvatarImage(_ image: UIImage) -> UIImage? {
let size = CGSize(width: 36, height: 36)
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: 2, dy: 2)
UIBezierPath(ovalIn: imageRect).addClip()
image.draw(in: imageRect)
return UIGraphicsGetImageFromCurrentImageContext()
}
}
// MARK: - Collection / Table
extension TodayTrackDetailVC: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
if collectionView === rootView.memberCV {
return viewModel.members.count
}
return viewModel.dateItems.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if collectionView === rootView.memberCV {
let cell: GroupMemberListCell = collectionView.dequeueReusableCell(for: indexPath)
let model = viewModel.members[indexPath.item]
let selected = model.user_id == viewModel.selectedMemberId.value
let isMe = model.user_id == AppContextManager.shared.userId
cell.configure(model: model, isCurrentUser: isMe, isSelected: selected)
return cell
}
let cell: TodayTrackDateCell = collectionView.dequeueReusableCell(for: indexPath)
let item = viewModel.dateItems[indexPath.item]
let selected = item.key == viewModel.selectedDateKey.value
cell.configure(title: item.title, selected: selected)
return cell
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if collectionView === rootView.memberCV {
let model = viewModel.members[indexPath.item]
viewModel.selectedMemberId.accept(model.user_id)
return
}
let item = viewModel.dateItems[indexPath.item]
viewModel.selectedDateKey.accept(item.key)
}
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
if collectionView === rootView.memberCV {
return CGSize(width: 61, height: 90)
}
return rootView.dateItemSize()
}
}
extension TodayTrackDetailVC: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
tripList.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: TodayTrackTripCell = tableView.dequeueReusableCell(for: indexPath)
let trip = tripList[indexPath.row]
let isFirst = indexPath.row == 0
let isLast = indexPath.row == tripList.count - 1
cell.configure(trip: trip, isFirst: isFirst, isLast: isLast)
cell.onViewTapped = { [weak self] in
self?.selectTripAndCollapse(trip)
}
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard tripList.indices.contains(indexPath.row) else { return }
selectTripAndCollapse(tripList[indexPath.row])
}
private func selectTripAndCollapse(_ trip: ScheduleRecordModel) {
viewModel.selectTrip(trip)
collapseSheetIfExpanded()
}
}
extension TodayTrackDetailVC: MAMapViewDelegate {
func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
guard let polyline = overlay as? MAPolyline else { return nil }
let renderer = MAPolylineRenderer(polyline: polyline)
renderer?.strokeColor = UIColor(hexStr: "#58EDFF")
renderer?.lineWidth = 6
renderer?.lineJoinType = kMALineJoinRound
renderer?.lineCapType = kMALineCapRound
return renderer
}
func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
guard !(annotation is MAUserLocation) else { return nil }
if annotation is HistoryTrackPlaybackAnnotation {
let id = "historyTrack.playback"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil {
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
}
view?.annotation = annotation
let headPic = viewModel.selectedMember?.displayHeadPic ?? ""
view?.image = Self.playbackAvatarImage(HeadPic.image(for: headPic))
HeadPic.load(headPic) { [weak view] image in
view?.image = Self.playbackAvatarImage(image)
}
view?.centerOffset = CGPoint(x: 0, y: -18)
view?.zIndex = 20
return view
}
if annotation is HistoryTrackStayAnnotation {
let id = "historyTrack.stay"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil {
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
}
view?.annotation = annotation
view?.image = Self.stayPointImage()
view?.centerOffset = .zero
view?.zIndex = 8
view?.canShowCallout = false
return view
}
if let duration = annotation as? HistoryTrackDurationAnnotation {
let id = "historyTrack.duration"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id) as? HistoryTrackDurationAnnotationView
if view == nil {
view = HistoryTrackDurationAnnotationView(annotation: annotation, reuseIdentifier: id)
}
view?.annotation = annotation
view?.configure(text: duration.text)
return view
}
guard let pointAnn = annotation as? MAPointAnnotation else { return nil }
if pointAnn.title == "start" {
let id = "historyTrack.start"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil {
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
}
view?.annotation = annotation
view?.image = UIImage(named: "Home/HistoryTrack/start")
view?.centerOffset = CGPoint(x: 0, y: -14)
view?.zIndex = 10
return view
}
if pointAnn.title == "end" {
let id = "historyTrack.end"
var view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
if view == nil {
view = MAAnnotationView(annotation: annotation, reuseIdentifier: id)
}
view?.annotation = annotation
view?.image = UIImage(named: "Home/HistoryTrack/end")
view?.centerOffset = CGPoint(x: 0, y: -14)
view?.zIndex = 10
return view
}
return nil
}
func mapView(_ mapView: MAMapView!, didSelect view: MAAnnotationView!) {
guard let stay = view.annotation as? HistoryTrackStayAnnotation else { return }
mapView.deselectAnnotation(stay, animated: false)
suppressMapTapHide = true
showStayDuration(at: stay.coordinate, minutes: stay.minutes)
DispatchQueue.main.async { [weak self] in
self?.suppressMapTapHide = false
}
}
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
guard !suppressMapTapHide else { return }
hideStayDuration()
}
}
private final class HistoryTrackPlaybackAnnotation: MAPointAnnotation {}
private final class HistoryTrackStayAnnotation: MAPointAnnotation {
var minutes: Int = 0
}
private final class HistoryTrackDurationAnnotation: MAPointAnnotation {
var text: String = ""
}
private final class HistoryTrackDurationAnnotationView: MAAnnotationView {
private let bubble = UIView()
private let iconView = UIImageView(image: UIImage(named: "Home/HistoryTrack/clock"))
private let label = UILabel()
override init(annotation: MAAnnotation?, reuseIdentifier: String?) {
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
bubble.backgroundColor = .white
bubble.layer.cornerRadius = 16
bubble.layer.shadowColor = UIColor.black.withAlphaComponent(0.12).cgColor
bubble.layer.shadowOpacity = 1
bubble.layer.shadowOffset = CGSize(width: 0, height: 2)
bubble.layer.shadowRadius = 6
addSubview(bubble)
iconView.contentMode = .scaleAspectFit
bubble.addSubview(iconView)
label.font = .systemFont(ofSize: 13, weight: .semibold)
label.textColor = UIColor(hexStr: "#293445")
bubble.addSubview(label)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func configure(text: String) {
label.text = text
let textWidth = ceil((text as NSString).size(withAttributes: [.font: label.font as Any]).width)
let width = max(96, textWidth + 44)
bounds = CGRect(x: 0, y: 0, width: width, height: 32)
centerOffset = CGPoint(x: 0, y: -36)
bubble.frame = bounds
iconView.frame = CGRect(x: 10, y: 7, width: 18, height: 18)
label.frame = CGRect(x: 32, y: 0, width: width - 40, height: 32)
}
}
private extension CLLocationCoordinate2D {
func distance(to coordinate: CLLocationCoordinate2D) -> CLLocationDistance {
let fromLocation = CLLocation(latitude: latitude, longitude: longitude)
let toLocation = CLLocation(latitude: coordinate.latitude, longitude: 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
)
}
}