@ -21,6 +21,7 @@ final class WildReportRiskMapViewController: BaseViewController {
private let mapView = MKMapView ( )
private let scenicCapsule = UIView ( )
private let scenicNameLabel = UILabel ( )
private let locateButton = UIButton ( type : . system )
private let legendBar = UIStackView ( )
private let detailContainerView = UIView ( )
private let scrollView = UIScrollView ( )
@ -87,6 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
mapContainerView . addSubview ( mapView )
configureScenicCapsule ( )
configureLocateButton ( )
configureLegendBar ( )
configureDetailArea ( )
refreshContent ( animated : false )
@ -106,6 +108,10 @@ final class WildReportRiskMapViewController: BaseViewController {
make . top . leading . equalToSuperview ( ) . inset ( 12 )
make . height . equalTo ( 34 )
}
locateButton . snp . makeConstraints { make in
make . top . trailing . equalToSuperview ( ) . inset ( 12 )
make . size . equalTo ( 44 )
}
detailContainerView . snp . makeConstraints { make in
make . height . greaterThanOrEqualTo ( 180 )
}
@ -117,9 +123,6 @@ final class WildReportRiskMapViewController: BaseViewController {
self ? . refreshContent ( animated : true )
}
}
viewModel . onShowMessage = { [ weak self ] message in
Task { @ MainActor in self ? . showToast ( message ) }
}
}
private func configureScenicCapsule ( ) {
@ -151,6 +154,20 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
private func configureLocateButton ( ) {
locateButton . backgroundColor = UIColor . white . withAlphaComponent ( 0.96 )
locateButton . tintColor = AppColor . primary
locateButton . setImage ( UIImage ( systemName : " location.fill " ) , for : . normal )
locateButton . layer . cornerRadius = 22
locateButton . layer . shadowColor = UIColor . black . cgColor
locateButton . layer . shadowOpacity = 0.1
locateButton . layer . shadowRadius = 8
locateButton . layer . shadowOffset = CGSize ( width : 0 , height : 4 )
locateButton . accessibilityLabel = " 回到我的位置 "
locateButton . addTarget ( self , action : #selector ( locateButtonTapped ) , for : . touchUpInside )
mapContainerView . addSubview ( locateButton )
}
private func configureLegendBar ( ) {
legendBar . axis = . horizontal
legendBar . alignment = . center
@ -182,8 +199,8 @@ final class WildReportRiskMapViewController: BaseViewController {
make . edges . equalToSuperview ( )
}
contentStack . snp . makeConstraints { make in
make . edges . equalToSuperview ( ) . inset ( UIEdgeInsets ( top : 14 , left : 18 , bottom : 18 , right : 18 ) )
make . width . equalTo ( scrollView . snp . width ) . offset ( - 36 )
make . edges . equalToSuperview ( ) . inset ( 16 )
make . width . equalTo ( scrollView . snp . width ) . offset ( - 32 )
}
}
@ -191,6 +208,7 @@ final class WildReportRiskMapViewController: BaseViewController {
private func refreshContent ( animated : Bool ) {
setRiskMapLoadingVisible ( viewModel . isLoading )
scenicNameLabel . text = viewModel . scenicAreaName
updateLocateButtonState ( )
syncMapAnnotations ( )
mapView . setRegion ( viewModel . region , animated : animated )
updateAnnotationSelection ( )
@ -205,6 +223,13 @@ final class WildReportRiskMapViewController: BaseViewController {
isVisible ? showLoading ( ) : hideLoading ( )
}
@ MainActor
private func updateLocateButtonState ( ) {
let hasCurrentLocation = viewModel . markers . contains { $0 . kind = = . selfLocation }
locateButton . isEnabled = hasCurrentLocation
locateButton . alpha = hasCurrentLocation ? 1 : 0.46
}
private func syncMapAnnotations ( ) {
let oldAnnotations = mapView . annotations . compactMap { $0 as ? WildReportMapAnnotation }
mapView . removeAnnotations ( oldAnnotations )
@ -258,39 +283,63 @@ final class WildReportRiskMapViewController: BaseViewController {
return
}
let card = WildRepor tCardView ( spacing : 16 , inset : 16 )
card . layer . cornerRadius = 16
card . stack . addArrangedSubview ( makeHeader ( marker : marker , clue : clue ) )
if viewModel . isLoadingDetail {
card . stack . addArrangedSubview ( makeInlineLoading ( text : " 正在加载标记详情... " ) )
}
if let nearest = clue . nearestAbnormalText {
card . stack . addArrangedSubview ( makeHighlight ( text : nearest , color : AppColor . danger ) )
}
addInfoRows ( for : clue , to : card . stack )
addDescriptionBlocks ( for : clue , to : card . stack )
addMediaRow ( for : clue , to : card . stack )
addProcessingBlock ( for : clue , to : card . stack )
card . stack . addArrangedSubview ( makeButtonRow ( for : clue ) )
let card = makeRadarResul tCard( clue : clue )
contentStack . addArrangedSubview ( card )
}
private func makeHeader ( marker : WildReportMapMarker , clue : WildReportRadarActiveClue ) -> UIView {
private func makeRadarResultCard ( clue : WildReportRadarActiveClue ) -> UIView {
let card = WildReportCardView ( spacing : 14 , inset : 16 )
card . layer . cornerRadius = 16
card . stack . spacing = 14
card . stack . addArrangedSubview ( makeRadarHeader ( clue : clue ) )
if clue . type = = . blue , let nearest = clue . nearestAbnormalText {
card . stack . addArrangedSubview ( makeCompareText ( nearest ) )
}
if clue . type = = . green {
addPhotographerProfile ( for : clue , to : card . stack )
}
if isReportClue ( clue ) {
card . stack . addArrangedSubview ( makeReporterInfo ( clue : clue ) )
addReportContents ( for : clue , to : card . stack )
addRadarImages ( for : clue , to : card . stack )
if clue . processingStarted , let record = clue . record {
card . stack . addArrangedSubview ( makeProcessingPanel ( clue : clue , record : record ) )
}
}
if clue . type = = . green , let summary = clue . summary {
card . stack . addArrangedSubview ( makeCompareText ( summary ) )
}
if clue . type = = . green , let detail = clue . detail {
card . stack . addArrangedSubview ( makeDetailSection ( detail ) )
}
if ! clue . completed {
card . stack . addArrangedSubview ( makeRadarActionButtons ( for : clue ) )
}
return card
}
private func makeRadarHeader ( clue : WildReportRadarActiveClue ) -> UIView {
let row = UIStackView ( )
row . axis = . horizontal
row . alignment = . center
row . alignment = . top
row . spacing = 12
let iconWrap = UIView ( )
iconWrap . backgroundColor = marker . kind . color . withAlphaComponent ( 0.14 )
iconWrap . layer . cornerRadius = 2 2
let icon = UIImageView ( image : UIImage ( systemName : iconName ( for : marker . kind ) ) )
icon . tintColor = marker . kind . color
iconWrap . backgroundColor = headerIconColor ( for : clue )
iconWrap . layer . cornerRadius = 1 2
let icon = UIImageView ( image : UIImage ( systemName : " location.fill " ) )
icon . tintColor = . white
icon . contentMode = . scaleAspectFit
iconWrap . addSubview ( icon )
icon . snp . makeConstraints { make in
make . center . equalToSuperview ( )
make . size . equalTo ( 22 )
make . size . equalTo ( 20 )
}
iconWrap . snp . makeConstraints { make in
make . size . equalTo ( 44 )
@ -298,14 +347,34 @@ final class WildReportRiskMapViewController: BaseViewController {
let textStack = UIStackView ( )
textStack . axis = . vertical
textStack . spacing = 4
let title = WildReportUI . label ( clue . displayTitle , font : . systemFont ( ofSize : 17 , weight : . bold ) , color : AppColor . textPrimary , lines : 2 )
let metaText = [ clue . distanceText ? ? clue . distance , clue . updatedAt ] . compactMap { $0 } . joined ( separator : " · " )
let meta = WildReportUI . label ( metaText . isEmpty ? marker . title : metaText , font : . systemFont ( ofSize : 12 ) , color : AppColor . textSecondary , lines : 1 )
textStack . addArrangedSubview ( title )
textStack . addArrangedSubview ( meta )
textStack . spacing = 6
if isReportClue ( clue ) {
let title = WildReportUI . label ( " 线索 ID: \( clue . id ) " , font : . systemFont ( ofSize : 16 , weight : . bold ) , color : AppColor . textPrimary , lines : 2 )
textStack . addArrangedSubview ( title )
let metaRow = UIStackView ( )
metaRow . axis = . horizontal
metaRow . spacing = 12
if let distance = clue . distanceText ? ? clue . distance {
metaRow . addArrangedSubview ( WildReportUI . label ( " 距离:距离您 \( distance ) " , font : . systemFont ( ofSize : 12 ) , color : AppColor . textSecondary ) )
}
if let updatedAt = clue . updatedAt {
metaRow . addArrangedSubview ( WildReportUI . label ( " 时间: \( updatedAt ) " , font : . systemFont ( ofSize : 12 ) , color : AppColor . textSecondary ) )
}
if ! metaRow . arrangedSubviews . isEmpty {
textStack . addArrangedSubview ( metaRow )
}
} else {
let title = WildReportUI . label ( clue . displayTitle , font : . systemFont ( ofSize : 16 , weight : . bold ) , color : AppColor . textPrimary , lines : 2 )
textStack . addArrangedSubview ( title )
if clue . type = = . green {
let metaText = [ clue . distance . map { " 距蓝点 \( $0 ) " } , clue . direction , clue . updatedAt ] . compactMap { $0 } . joined ( separator : " " )
if ! metaText . isEmpty {
textStack . addArrangedSubview ( WildReportUI . label ( metaText , font : . systemFont ( ofSize : 12 ) , color : AppColor . textSecondary ) )
}
}
}
let badge = WildReportRiskStatusBadge ( text : clue . statusText , color : marker . kind . color )
let badge = WildReportRiskStatusBadge ( text : clue . statusText , color : statusColor ( for : clue ) )
row . addArrangedSubview ( iconWrap )
row . addArrangedSubview ( textStack )
row . addArrangedSubview ( badge )
@ -314,6 +383,341 @@ final class WildReportRiskMapViewController: BaseViewController {
return row
}
private func addPhotographerProfile ( for clue : WildReportRadarActiveClue , to stack : UIStackView ) {
let row = UIStackView ( )
row . axis = . horizontal
row . alignment = . center
row . spacing = 12
let avatarWrap = UIView ( )
avatarWrap . backgroundColor = AppColor . success . withAlphaComponent ( 0.12 )
avatarWrap . layer . cornerRadius = 28
let avatar = UIImageView ( image : UIImage ( systemName : clue . avatar ? ? " person.crop.circle.fill " ) )
avatar . tintColor = AppColor . success
avatar . contentMode = . scaleAspectFit
avatarWrap . addSubview ( avatar )
avatar . snp . makeConstraints { make in
make . center . equalToSuperview ( )
make . size . equalTo ( 42 )
}
avatarWrap . snp . makeConstraints { make in
make . size . equalTo ( 56 )
}
let textStack = UIStackView ( )
textStack . axis = . vertical
textStack . spacing = 4
if let name = clue . name {
textStack . addArrangedSubview ( WildReportUI . label ( name , font : . systemFont ( ofSize : 16 , weight : . bold ) , color : AppColor . textPrimary ) )
}
if let storeName = clue . storeName {
textStack . addArrangedSubview ( WildReportUI . label ( storeName , font : . systemFont ( ofSize : 13 , weight : . semibold ) , color : AppColor . success ) )
}
row . addArrangedSubview ( avatarWrap )
row . addArrangedSubview ( textStack )
stack . addArrangedSubview ( row )
}
private func makeReporterInfo ( clue : WildReportRadarActiveClue ) -> UIView {
let box = UIView ( )
box . backgroundColor = UIColor ( hex : 0xF9FAFB )
box . layer . cornerRadius = 10
let inner = UIStackView ( )
inner . axis = . vertical
inner . spacing = 10
box . addSubview ( inner )
inner . snp . makeConstraints { make in
make . edges . equalToSuperview ( ) . inset ( 12 )
}
inner . addArrangedSubview ( makeInfoLabelRow ( label : " 举报人 " , value : clue . reporterName ? ? " - " ) )
inner . addArrangedSubview ( makePhoneRow (
label : " 举报人手机号 " ,
phone : clue . reporterPhone ,
displayPhone : clue . maskedReporterPhone ? ? clue . reporterPhone
) )
return box
}
private func addReportContents ( for clue : WildReportRadarActiveClue , to stack : UIStackView ) {
guard ! clue . reportContents . isEmpty else { return }
let section = UIStackView ( )
section . axis = . vertical
section . spacing = 10
for item in clue . reportContents {
let itemStack = UIStackView ( )
itemStack . axis = . vertical
itemStack . spacing = 4
if ! item . time . isEmpty {
itemStack . addArrangedSubview ( WildReportUI . label ( item . time , font : . systemFont ( ofSize : 11 , weight : . semibold ) , color : AppColor . textSecondary ) )
}
itemStack . addArrangedSubview ( makeLineSpacedLabel ( item . content , font : . systemFont ( ofSize : 13 ) , color : AppColor . textPrimary ) )
section . addArrangedSubview ( itemStack )
}
stack . addArrangedSubview ( section )
}
private func addRadarImages ( for clue : WildReportRadarActiveClue , to stack : UIStackView ) {
guard ! clue . images . isEmpty else { return }
let section = UIStackView ( )
section . axis = . vertical
section . spacing = 10
section . addArrangedSubview ( WildReportUI . label ( " 现场图片/视频 " , font : . systemFont ( ofSize : 15 , weight : . bold ) , color : AppColor . textPrimary ) )
let scroll = UIScrollView ( )
scroll . showsHorizontalScrollIndicator = false
let row = UIStackView ( )
row . axis = . horizontal
row . spacing = 10
scroll . addSubview ( row )
row . snp . makeConstraints { make in
make . edges . equalToSuperview ( )
make . height . equalTo ( 108 )
}
for url in clue . images {
let tile = WildReportRiskImageTile ( url : url , size : 108 )
tile . onTap = { [ weak self ] in self ? . presentImagePreview ( url : url ) }
row . addArrangedSubview ( tile )
}
section . addArrangedSubview ( scroll )
scroll . snp . makeConstraints { make in
make . height . equalTo ( 108 )
}
stack . addArrangedSubview ( section )
}
private func makeProcessingPanel ( clue : WildReportRadarActiveClue , record : WildReportRadarProcessRecord ) -> UIView {
let box = UIView ( )
box . backgroundColor = UIColor ( hex : 0xFFFBEB )
box . layer . cornerRadius = 10
let inner = UIStackView ( )
inner . axis = . vertical
inner . spacing = 10
box . addSubview ( inner )
inner . snp . makeConstraints { make in
make . edges . equalToSuperview ( ) . inset ( 12 )
}
inner . addArrangedSubview ( makeInfoLabelRow ( label : " 处理人 " , value : record . handlerName ) )
inner . addArrangedSubview ( makeInfoLabelRow (
label : " 状态 " ,
value : clue . completed ? " 已处理 " : " 正在处理中 " ,
valueColor : clue . completed ? AppColor . success : UIColor ( hex : 0xF59E0B )
) )
inner . addArrangedSubview ( makePhoneRow ( label : " 联系电话 " , phone : record . phone , displayPhone : record . maskedPhone ) )
let progressStack = UIStackView ( )
progressStack . axis = . vertical
progressStack . spacing = 10
progressStack . addArrangedSubview ( WildReportUI . label ( " 处理进度 " , font : . systemFont ( ofSize : 14 , weight : . bold ) , color : AppColor . textPrimary ) )
for item in clue . processingTimeline {
progressStack . addArrangedSubview ( makeTimelineRow ( item ) )
}
inner . addArrangedSubview ( progressStack )
return box
}
private func makeTimelineRow ( _ item : WildReportRadarProcessingTimelineItem ) -> UIView {
let row = UIStackView ( )
row . axis = . horizontal
row . alignment = . top
row . spacing = 10
let dot = UIView ( )
dot . backgroundColor = timelineDotColor ( item . status )
dot . layer . cornerRadius = 5
dot . snp . makeConstraints { make in
make . size . equalTo ( 10 )
}
let dotContainer = UIView ( )
dotContainer . addSubview ( dot )
dot . snp . makeConstraints { make in
make . top . equalToSuperview ( ) . offset ( 4 )
make . centerX . equalToSuperview ( )
}
dotContainer . snp . makeConstraints { make in
make . width . equalTo ( 10 )
}
let textStack = UIStackView ( )
textStack . axis = . vertical
textStack . spacing = 2
textStack . addArrangedSubview ( WildReportUI . label ( item . title , font : . systemFont ( ofSize : 13 , weight : . semibold ) , color : AppColor . textPrimary ) )
textStack . addArrangedSubview ( WildReportUI . label ( item . time , font : . systemFont ( ofSize : 12 ) , color : AppColor . textSecondary ) )
row . addArrangedSubview ( dotContainer )
row . addArrangedSubview ( textStack )
return row
}
private func makeRadarActionButtons ( for clue : WildReportRadarActiveClue ) -> UIView {
let row = UIStackView ( )
row . axis = . horizontal
row . spacing = 10
row . distribution = . fillEqually
let navButton = makeRadarButton ( title : clue . actionText , imageName : " map.fill " , color : AppColor . primary )
navButton . addTarget ( self , action : #selector ( navigateTapped ) , for : . touchUpInside )
row . addArrangedSubview ( navButton )
if isReportClue ( clue ) , ! clue . completed {
let shareButton = makeRadarButton ( title : " 分享 " , imageName : " square.and.arrow.up " , color : AppColor . success )
shareButton . addTarget ( self , action : #selector ( shareTapped ) , for : . touchUpInside )
row . addArrangedSubview ( shareButton )
}
[ navButton ] . forEach { button in
button . snp . makeConstraints { make in make . height . equalTo ( 44 ) }
}
return row
}
private func makeRadarButton ( title : String , imageName : String , color : UIColor ) -> UIButton {
let button = UIButton ( type : . system )
button . titleLabel ? . font = . systemFont ( ofSize : 15 , weight : . bold )
button . setTitle ( title , for : . normal )
button . setImage ( UIImage ( systemName : imageName ) , for : . normal )
button . tintColor = . white
button . setTitleColor ( . white , for : . normal )
button . backgroundColor = color
button . layer . cornerRadius = 12
button . imageEdgeInsets = UIEdgeInsets ( top : 0 , left : - 3 , bottom : 0 , right : 3 )
button . contentEdgeInsets = UIEdgeInsets ( top : 0 , left : 12 , bottom : 0 , right : 12 )
button . snp . makeConstraints { make in make . height . equalTo ( 44 ) }
return button
}
private func makeCompareText ( _ text : String ) -> UIView {
let label = makeLineSpacedLabel ( text , font : . systemFont ( ofSize : 13 ) , color : AppColor . textSecondary )
label . backgroundColor = UIColor ( hex : 0xF3F4F6 )
label . layer . cornerRadius = 10
label . clipsToBounds = true
label . textInsets = UIEdgeInsets ( top : 12 , left : 12 , bottom : 12 , right : 12 )
return label
}
private func makeDetailSection ( _ detail : String ) -> UIView {
let row = UIStackView ( )
row . axis = . horizontal
row . alignment = . top
row . spacing = 8
let icon = UIImageView ( image : UIImage ( systemName : " doc.text.fill " ) )
icon . tintColor = AppColor . primary
icon . contentMode = . scaleAspectFit
icon . snp . makeConstraints { make in
make . size . equalTo ( 16 )
}
row . addArrangedSubview ( icon )
row . addArrangedSubview ( makeLineSpacedLabel ( detail , font : . systemFont ( ofSize : 13 ) , color : AppColor . textPrimary ) )
return row
}
private func makeInfoLabelRow ( label : String , value : String , valueColor : UIColor = AppColor . textPrimary ) -> UIView {
let row = UIStackView ( )
row . axis = . horizontal
row . alignment = . center
row . spacing = 12
let titleLabel = WildReportUI . label ( label , font : . systemFont ( ofSize : 13 ) , color : AppColor . textSecondary )
let valueLabel = WildReportUI . label ( value , font : . systemFont ( ofSize : 14 , weight : . semibold ) , color : valueColor , lines : 0 )
valueLabel . textAlignment = . right
row . addArrangedSubview ( titleLabel )
row . addArrangedSubview ( valueLabel )
titleLabel . setContentHuggingPriority ( . required , for : . horizontal )
valueLabel . setContentHuggingPriority ( . defaultLow , for : . horizontal )
return row
}
private func makePhoneRow ( label : String , phone : String ? , displayPhone : String ? ) -> UIView {
let row = UIStackView ( )
row . axis = . horizontal
row . alignment = . center
row . spacing = 12
let titleLabel = WildReportUI . label ( label , font : . systemFont ( ofSize : 13 ) , color : AppColor . textSecondary )
row . addArrangedSubview ( titleLabel )
row . addArrangedSubview ( UIView ( ) )
guard let displayPhone , ! displayPhone . isEmpty else {
row . addArrangedSubview ( WildReportUI . label ( " - " , font : . systemFont ( ofSize : 14 , weight : . semibold ) , color : AppColor . textPrimary ) )
return row
}
let button = UIButton ( type : . system )
button . titleLabel ? . font = . systemFont ( ofSize : 14 , weight : . semibold )
button . setTitle ( displayPhone , for : . normal )
button . setImage ( UIImage ( systemName : " phone.fill " ) , for : . normal )
button . tintColor = AppColor . primary
button . setTitleColor ( AppColor . primary , for : . normal )
button . semanticContentAttribute = . forceRightToLeft
button . imageEdgeInsets = UIEdgeInsets ( top : 0 , left : 4 , bottom : 0 , right : - 4 )
if let phone {
button . addAction ( UIAction { [ weak self ] _ in
self ? . callPhone ( phone )
} , for : . touchUpInside )
}
row . addArrangedSubview ( button )
button . setContentHuggingPriority ( . required , for : . horizontal )
return row
}
private func makeLineSpacedLabel ( _ text : String , font : UIFont , color : UIColor ) -> WildReportRiskInsetLabel {
let label = WildReportRiskInsetLabel ( )
label . font = font
label . textColor = color
label . numberOfLines = 0
let paragraph = NSMutableParagraphStyle ( )
paragraph . lineSpacing = 4
label . attributedText = NSAttributedString (
string : text ,
attributes : [ . paragraphStyle : paragraph , . font : font , . foregroundColor : color ]
)
return label
}
private func isReportClue ( _ clue : WildReportRadarActiveClue ) -> Bool {
clue . type = = . red || clue . type = = . yellow
}
private func headerIconColor ( for clue : WildReportRadarActiveClue ) -> UIColor {
if isReportClue ( clue ) , clue . processingStarted , ! clue . completed {
return UIColor ( hex : 0xF59E0B )
}
switch clue . type {
case . blue : return AppColor . primary
case . green : return AppColor . success
case . red : return AppColor . danger
case . yellow : return UIColor ( hex : 0xF59E0B )
}
}
private func statusColor ( for clue : WildReportRadarActiveClue ) -> UIColor {
if clue . completed { return AppColor . success }
if clue . processingStarted , ! clue . completed { return UIColor ( hex : 0xF59E0B ) }
switch clue . type {
case . blue : return AppColor . primary
case . green : return AppColor . success
case . red : return AppColor . danger
case . yellow : return UIColor ( hex : 0xF59E0B )
}
}
private func timelineDotColor ( _ status : String ) -> UIColor {
switch status {
case " done " : return AppColor . success
case " current " : return UIColor ( hex : 0xF59E0B )
default : return UIColor ( hex : 0xD1D5DB )
}
}
private func callPhone ( _ phone : String ) {
guard phone . contains ( " * " ) = = false ,
let url = URL ( string : " tel:// \( phone ) " )
else { return }
UIApplication . shared . open ( url )
}
private func addInfoRows ( for clue : WildReportRadarActiveClue , to stack : UIStackView ) {
if let name = clue . name {
stack . addArrangedSubview ( WildReportInfoRowView ( title : clue . type = = . green ? " 摄影师 " : " 点位 " , value : name ) )
@ -494,6 +898,10 @@ final class WildReportRiskMapViewController: BaseViewController {
@objc private func shareTapped ( ) {
viewModel . shareSelectedClue ( )
}
@objc private func locateButtonTapped ( ) {
viewModel . focusCurrentLocation ( )
}
}
extension WildReportRiskMapViewController : MKMapViewDelegate {
@ -533,16 +941,17 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
// / 风 险 地 图 自 定 义 脉 冲 圆 点 标 记 。
private final class WildReportPulseMarkerAnnotationView : MKAnnotationView {
private let pulseOuter = UIView ( )
private let pulseIn ner = UIView ( )
private let titleBadge = WildReportMapMarkerTitleBadge ( )
private let markerContai ner = UIView ( )
private let pulseRings = ( 0. . < 3 ) . map { _ in UIView ( ) }
private let selectedRing = UIView ( )
private let dot = UIView ( )
private let label = UILabel ( )
private var showsTitleBadge = false
override init ( annotation : MKAnnotation ? , reuseIdentifier : String ? ) {
super . init ( annotation : annotation , reuseIdentifier : reuseIdentifier )
frame = CGRect ( x : 0 , y : 0 , width : 56 , height : 5 6)
centerOffset = CGPoint ( x : 0 , y : - 12 )
frame = CGRect ( x : 0 , y : 0 , width : 72 , height : 9 6)
centerOffset = . zero
canShowCallout = false
setupUI ( )
}
@ -554,41 +963,51 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
func apply ( marker : WildReportMapMarker , selected : Bool ) {
let color = marker . kind . color
pulseOuter . lay er. borderColor = color . withAlphaComponent ( 0.18 ) . cgColor
pulseInner . layer . borderColor = colo r. w ithAlphaComponent ( 0.28 ) . cgColor
selected Ring. layer . borderColor = color . cgColor
updateTitleBadgeVisibility ( mark er. kind = = . selfLocation )
titleBadge . text = marke r. t itle
pulse Rings . forEach { ring in
ring . layer . borderColor = color . withAlphaComponent ( 0.32 ) . cgColor
}
selectedRing . layer . borderColor = color . withAlphaComponent ( 0.45 ) . cgColor
selectedRing . isHidden = ! selected
dot . backgroundColor = color
label . isHidden = marker . kind != . selfLocation
label . text = " 我 "
startPulseIfNeeded ( )
}
private func setupUI ( ) {
backgroundColor = . clear
[ pulseOuter , pulseInner , selectedRing , dot , label ] . forEach ( addSubview )
[ pulseOuter , pulseInner , selectedRing ] . forEach { view in
titleBadge . isHidden = true
addSubview ( titleBadge )
addSubview ( markerContainer )
pulseRings . forEach ( markerContainer . addSubview )
markerContainer . addSubview ( selectedRing )
markerContainer . addSubview ( dot )
pulseRings . forEach { view in
view . backgroundColor = . clear
view . layer . borderWidth = 2
view . layer . cornerRadius = 10
view . isUserInteractionEnabled = false
}
pulseOuter . layer . cornerRadius = 20
pulseInner . layer . cornerRadius = 14
selectedRing . layer . cornerRadius = 14
selectedRing . layer . borderWidth = 2
dot . layer . cornerRadius = 7
dot . layer . borderWidth = 2
dot . layer . borderColor = UIColor . white . cgColor
label . font = . systemFont ( ofSize : 10 , weight : . bold )
label . textColor = . white
label . textAlignment = . center
pulseOuter . snp . makeConstraints { make in
make . center . equalToSuperview ( )
make . size . equalTo ( 40 )
titleBadge . snp . makeConstraints { make in
make . top . centerX . equalToSuperview ( )
make . height . equalTo ( 24 )
}
pulseIn ner. snp . makeConstraints { make in
markerContai ner. snp . makeConstraints { make in
make . center . equalToSuperview ( )
make . size . equalTo ( 28 )
make . size . equalTo ( 56 )
}
pulseRings . forEach { ring in
ring . snp . makeConstraints { make in
make . center . equalToSuperview ( )
make . size . equalTo ( 20 )
}
}
selectedRing . snp . makeConstraints { make in
make . center . equalToSuperview ( )
@ -598,21 +1017,81 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
make . center . equalToSuperview ( )
make . size . equalTo ( 14 )
}
label . snp . makeConstraints { make in
make . center . equalTo ( dot )
make . size . equalTo ( 14 )
}
startPulse ( )
}
private func startPulse ( ) {
let animation = CABasicAnimation ( keyPath : " opacity " )
animation . fromValue = 0.85
animation . toValue = 0.2
animation . duration = 1.2
animation . autoreverses = true
animation . repeatCount = . infinity
pulseOuter . layer . add ( animation , forKey : " opacityPulse " )
private func updateTitleBadgeVisibility ( _ isVisible : Bool ) {
guard showsTitleBadge != isVisible else { return }
showsTitleBadge = isVisible
titleBadge . isHidden = ! isVisible
markerContainer . snp . remakeConstraints { make in
if isVisible {
make . top . equalTo ( titleBadge . snp . bottom ) . offset ( 4 )
make . centerX . equalToSuperview ( )
} else {
make . center . equalToSuperview ( )
}
make . size . equalTo ( 56 )
}
}
private func startPulseIfNeeded ( ) {
guard pulseRings . first ? . layer . animation ( forKey : " riskMapPulseOpacity " ) = = nil else { return }
let endSizes : [ CGFloat ] = [ 28 , 40 , 52 ]
for ( index , ring ) in pulseRings . enumerated ( ) {
ring . layer . opacity = 0.72
let scale = CABasicAnimation ( keyPath : " transform.scale " )
scale . fromValue = 1
scale . toValue = endSizes [ index ] / 20
let opacity = CABasicAnimation ( keyPath : " opacity " )
opacity . fromValue = 0.72
opacity . toValue = 0
let animation = CAAnimationGroup ( )
animation . animations = [ scale , opacity ]
animation . timingFunction = CAMediaTimingFunction ( name : . easeOut )
animation . duration = 2
animation . repeatCount = . infinity
animation . isRemovedOnCompletion = false
ring . layer . add ( animation , forKey : " riskMapPulseOpacity " )
}
}
}
// / 风 险 地 图 当 前 位 置 标 记 标 题 胶 囊 。
private final class WildReportMapMarkerTitleBadge : UIView {
var text : String ? {
get { label . text }
set { label . text = newValue }
}
private let label = UILabel ( )
override init ( frame : CGRect ) {
super . init ( frame : frame )
setupUI ( )
}
@ available ( * , unavailable )
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
private func setupUI ( ) {
backgroundColor = . white
layer . cornerRadius = 12
layer . shadowColor = UIColor . black . cgColor
layer . shadowOpacity = 0.08
layer . shadowRadius = 4
layer . shadowOffset = CGSize ( width : 0 , height : 2 )
label . font = . systemFont ( ofSize : 11 , weight : . semibold )
label . textColor = AppColor . textPrimary
addSubview ( label )
label . snp . makeConstraints { make in
make . edges . equalToSuperview ( ) . inset ( UIEdgeInsets ( top : 4 , left : 8 , bottom : 4 , right : 8 ) )
}
}
}
@ -676,12 +1155,31 @@ private final class WildReportRiskStatusBadge: UILabel {
}
}
// / 风 险 地 图 底 部 卡 片 带 内 边 距 文 本 。
private final class WildReportRiskInsetLabel : UILabel {
var textInsets = UIEdgeInsets . zero {
didSet { invalidateIntrinsicContentSize ( ) }
}
override func drawText ( in rect : CGRect ) {
super . drawText ( in : rect . inset ( by : textInsets ) )
}
override var intrinsicContentSize : CGSize {
let size = super . intrinsicContentSize
return CGSize (
width : size . width + textInsets . left + textInsets . right ,
height : size . height + textInsets . top + textInsets . bottom
)
}
}
// / 风 险 地 图 举 报 图 片 缩 略 图 。
private final class WildReportRiskImageTile : UIView {
private let imageView = UIImageView ( )
var onTap : ( ( ) -> Void ) ?
init ( url : String ) {
init ( url : String , size : CGFloat = 72 ) {
super . init ( frame : . zero )
backgroundColor = AppColor . pageBackgroundSoft
layer . cornerRadius = 10
@ -699,7 +1197,7 @@ private final class WildReportRiskImageTile: UIView {
}
addGestureRecognizer ( UITapGestureRecognizer ( target : self , action : #selector ( tapped ) ) )
snp . makeConstraints { make in
make . size . equalTo ( 72 )
make . size . equalTo ( size )
}
}