748 lines
28 KiB
Swift
748 lines
28 KiB
Swift
//
|
||
// PhoneReportDetailView.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
import Kingfisher
|
||
|
||
final class PhoneReportDetailView: UIView {
|
||
|
||
lazy var navView: BaseNavigationView = {
|
||
let nav = BaseNavigationView(title: "手机报告")
|
||
return nav
|
||
}()
|
||
|
||
lazy var navBgView: UIImageView = {
|
||
let iv = UIImageView()
|
||
iv.image = UIImage(named: "Common/navBar_bg_2")
|
||
iv.contentMode = .scaleAspectFill
|
||
return iv
|
||
}()
|
||
|
||
let switchGroupBtn: UIButton = {
|
||
let btn = UIButton(type: .custom)
|
||
btn.backgroundColor = .white.withAlphaComponent(0.8)
|
||
btn.setTitle(" 切换圈子 ", for: .normal)
|
||
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||
btn.setTitleColor(UIColor(hexStr: "#293445"), for: .normal)
|
||
btn.cornerRadius = 8
|
||
btn.extendEdgeInsets = UIEdgeInsets(top: 20, left: 20, bottom: 15, right: 18)
|
||
return btn
|
||
}()
|
||
|
||
lazy var memberCV: UICollectionView = {
|
||
let layout = UICollectionViewFlowLayout()
|
||
layout.scrollDirection = .horizontal
|
||
layout.itemSize = CGSize(width: 61, height: 90)
|
||
layout.minimumLineSpacing = 12
|
||
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||
cv.backgroundColor = .clear
|
||
cv.showsHorizontalScrollIndicator = false
|
||
cv.contentInset = UIEdgeInsets(top: 5, left: 5, bottom: 0, right: 5)
|
||
cv.register(GroupMemberListCell.self)
|
||
// cv.applyPhoneReportCardStyle(cornerRadius: 20)
|
||
return cv
|
||
}()
|
||
|
||
let scrollView = UIScrollView()
|
||
let contentView = UIView()
|
||
private let memberTrailingFadeView = PhoneReportMemberTrailingFadeView()
|
||
|
||
// MARK: Status
|
||
let statusTitleIcon = UIImageView(image: UIImage(named: "Home/report_phone"))
|
||
let statusTitleLab = UILabel()
|
||
let statusCard = UIView()
|
||
let batteryRing = PhoneBatteryRingView()
|
||
let estimateLab = UILabel()
|
||
let brightnessCard = PhoneStatusMiniCard(iconName: "Home/phone_brightness", title: "亮度")
|
||
let volumeCard = PhoneStatusMiniCard(iconName: "Home/phone_volume", title: "音量")
|
||
let wifiCard = PhoneStatusMiniCard(iconName: "Home/phone_wifi", title: "WIFI")
|
||
let deviceCard = UIView()
|
||
let deviceNameLab = UILabel()
|
||
let storageLab = UILabel()
|
||
let storageBarTrack = UIView()
|
||
let storageBar = UIProgressView(progressViewStyle: .default)
|
||
let deviceImage = UIImageView(image: UIImage(named: "Home/phone_device"))
|
||
let brandLogoImage = UIImageView()
|
||
private var brandLogoURL = ""
|
||
private let statusDeniedOverlay = PrivilegeDeniedOverlay()
|
||
var onRemind: (() -> Void)?
|
||
|
||
// MARK: Charts
|
||
let screenSection = PhoneReportChartSectionView(
|
||
title: "屏幕使用时间",
|
||
iconName: "Home/report_screen",
|
||
summaryPrefix: "今日使用时长",
|
||
summaryColor: UIColor(hexStr: "#00ADFE"),
|
||
barGradient: (UIColor(hexStr: "#C0EAFF"), UIColor(hexStr: "#ECF9FF")),
|
||
highlightGradient: (UIColor(hexStr: "#16B3FF"), UIColor(hexStr: "#9EDFFF")),
|
||
tipColor: UIColor(hexStr: "#20B6FF"),
|
||
tipArrowImageName: "Home/report_screen_tip_arrow"
|
||
)
|
||
let unlockSection = PhoneReportChartSectionView(
|
||
title: "APP使用次数",
|
||
iconName: "Home/report_usage",
|
||
summaryPrefix: "今日使用次数",
|
||
summaryColor: UIColor(hexStr: "#68C025"),
|
||
barGradient: (UIColor(hexStr: "#DDFFA1"), UIColor(hexStr: "#F2FFDC")),
|
||
highlightGradient: (UIColor(hexStr: "#AAF52A"), UIColor(hexStr: "#E8FFC1")),
|
||
tipColor: UIColor(hexStr: "#86E73D"),
|
||
tipArrowImageName: "Home/report_usage_tip_arrow",
|
||
valueSuffix: "次"
|
||
)
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||
setupUI()
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
private func setupUI() {
|
||
addSubview(navBgView)
|
||
addSubview(navView)
|
||
|
||
navBgView.layoutChain
|
||
.edges(excludingEdge: .bottom)
|
||
.heightToWidth(160/375)
|
||
|
||
navView.layoutChain.top().edgesHorzontal().height(kNaviHeight)
|
||
// navView.addRightButton(switchGroupBtn)
|
||
|
||
addSubview(scrollView)
|
||
scrollView.showsVerticalScrollIndicator = false
|
||
scrollView.clipsToBounds = true
|
||
scrollView.delaysContentTouches = false
|
||
scrollView.canCancelContentTouches = true
|
||
scrollView.layoutChain
|
||
.topToBottomOfView(navView)
|
||
.edgesHorzontal()
|
||
.bottom()
|
||
scrollView.addSubview(contentView)
|
||
contentView.layoutChain.edges().widthToView(scrollView)
|
||
|
||
let memberView = UIView()
|
||
memberView.backgroundColor = .white
|
||
memberView.applyPhoneReportCardStyle(cornerRadius: 20)
|
||
|
||
contentView.addSubview(memberView)
|
||
memberView.addSubview(memberCV)
|
||
memberView.addSubview(memberTrailingFadeView)
|
||
|
||
memberView.layoutChain
|
||
.top(8)
|
||
.edgesHorzontal(15)
|
||
.height(100)
|
||
|
||
memberCV.layoutChain
|
||
.edgesVertical()
|
||
.edgesHorzontal()
|
||
|
||
memberTrailingFadeView.layoutChain
|
||
.top()
|
||
.right()
|
||
.bottom()
|
||
.width(52)
|
||
|
||
statusTitleIcon.contentMode = .scaleAspectFit
|
||
contentView.addSubview(statusTitleIcon)
|
||
statusTitleIcon.layoutChain
|
||
.topToBottomOfView(memberCV, offset: 12)
|
||
.left(15)
|
||
.width(18)
|
||
.height(18)
|
||
|
||
statusTitleLab.text = "手机状态"
|
||
statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||
statusTitleLab.textColor = UIColor(hexStr: "#293445")
|
||
contentView.addSubview(statusTitleLab)
|
||
statusTitleLab.layoutChain
|
||
.centerY(statusTitleIcon)
|
||
.leftToRightOfView(statusTitleIcon, offset: 6)
|
||
|
||
statusCard.backgroundColor = .white
|
||
statusCard.applyPhoneReportCardStyle(cornerRadius: 20)
|
||
contentView.addSubview(statusCard)
|
||
statusCard.layoutChain
|
||
.topToBottomOfView(statusTitleIcon, offset: 10)
|
||
.edgesHorzontal(15)
|
||
.height(175)
|
||
statusCard.addSubview(statusDeniedOverlay)
|
||
statusDeniedOverlay.layoutChain.edges()
|
||
statusDeniedOverlay.layer.cornerRadius = 20
|
||
|
||
let leftPanel = UIView()
|
||
leftPanel.backgroundColor = .clear
|
||
leftPanel.cornerRadius = 14
|
||
statusCard.addSubview(leftPanel)
|
||
leftPanel.layoutChain
|
||
.edges(excludingEdge: .right)
|
||
.width(136)
|
||
|
||
leftPanel.addSubview(batteryRing)
|
||
batteryRing.layoutChain.top(23).centerX().width(100).height(100)
|
||
|
||
estimateLab.font = .systemFont(ofSize: 12, weight: .medium)
|
||
estimateLab.textColor = UIColor(hexStr: "#293445")
|
||
estimateLab.textAlignment = .center
|
||
estimateLab.backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||
estimateLab.cornerRadius = 5
|
||
leftPanel.addSubview(estimateLab)
|
||
estimateLab.layoutChain
|
||
.topToBottomOfView(batteryRing, offset: 12)
|
||
.centerX()
|
||
.height(22)
|
||
.width(120)
|
||
|
||
let rightStack = UIStackView(arrangedSubviews: [brightnessCard, volumeCard, wifiCard])
|
||
rightStack.axis = .horizontal
|
||
rightStack.distribution = .fillEqually
|
||
rightStack.spacing = 8
|
||
statusCard.addSubview(rightStack)
|
||
rightStack.layoutChain
|
||
.top(12)
|
||
.leftToRightOfView(leftPanel, offset: 10)
|
||
.right(12)
|
||
.height(64)
|
||
|
||
deviceCard.backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||
deviceCard.cornerRadius = 12
|
||
statusCard.addSubview(deviceCard)
|
||
deviceCard.layoutChain
|
||
.topToBottomOfView(rightStack, offset: 8)
|
||
.leftToView(rightStack)
|
||
.right(12)
|
||
.bottom(12)
|
||
|
||
deviceNameLab.font = .systemFont(ofSize: 13, weight: .bold)
|
||
deviceNameLab.textColor = UIColor(hexStr: "#293445")
|
||
deviceCard.addSubview(deviceNameLab)
|
||
deviceNameLab.layoutChain.top(10).left(10).right(60)
|
||
|
||
storageLab.font = .systemFont(ofSize: 11, weight: .medium)
|
||
storageLab.textColor = UIColor(hexStr: "#6B7280")
|
||
deviceCard.addSubview(storageLab)
|
||
storageLab.layoutChain.topToBottomOfView(deviceNameLab, offset: 4).left(10)
|
||
|
||
storageBarTrack.backgroundColor = UIColor(hexStr: "#E0E0E0")
|
||
storageBarTrack.layer.cornerRadius = 5
|
||
storageBarTrack.clipsToBounds = true
|
||
deviceCard.addSubview(storageBarTrack)
|
||
storageBarTrack.layoutChain
|
||
.topToBottomOfView(storageLab, offset: 6)
|
||
.left(10)
|
||
.right(60)
|
||
.height(10)
|
||
.bottom(10)
|
||
|
||
storageBar.progressTintColor = UIColor(hexStr: "#16B3FF")
|
||
storageBar.trackTintColor = UIColor(hexStr: "#16B3FF", alpha: 0.3)
|
||
storageBarTrack.addSubview(storageBar)
|
||
storageBar.layoutChain
|
||
.edgesHorzontal(3)
|
||
.centerY()
|
||
|
||
deviceImage.contentMode = .scaleAspectFit
|
||
deviceCard.addSubview(deviceImage)
|
||
deviceImage.layoutChain.right(8).centerY().width(36).height(54)
|
||
|
||
brandLogoImage.contentMode = .scaleAspectFit
|
||
brandLogoImage.isHidden = true
|
||
deviceImage.addSubview(brandLogoImage)
|
||
brandLogoImage.layoutChain.center().width(18).height(18)
|
||
|
||
contentView.addSubview(screenSection)
|
||
screenSection.layoutChain
|
||
.topToBottomOfView(statusCard, offset: 16)
|
||
.edgesHorzontal(15)
|
||
|
||
contentView.addSubview(unlockSection)
|
||
unlockSection.layoutChain
|
||
.topToBottomOfView(screenSection, offset: 16)
|
||
.edgesHorzontal(15)
|
||
.bottom(24)
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
updateMemberFadeVisibility()
|
||
}
|
||
|
||
func updateMemberFadeVisibility() {
|
||
let rightInset = memberCV.adjustedContentInset.right
|
||
let maxOffsetX = max(
|
||
-memberCV.adjustedContentInset.left,
|
||
memberCV.contentSize.width - memberCV.bounds.width + rightInset
|
||
)
|
||
memberTrailingFadeView.isHidden = memberCV.contentOffset.x >= maxOffsetX - 1
|
||
|| memberCV.contentSize.width <= memberCV.bounds.width
|
||
}
|
||
|
||
func apply(_ snap: PhoneReportDetailViewModel.StatusSnapshot) {
|
||
batteryRing.configure(percent: snap.batteryPercent, text: snap.batteryText, isCharging: snap.isCharging)
|
||
estimateLab.text = "预计使用\(snap.estimateText)"
|
||
brightnessCard.setValue(snap.brightnessText)
|
||
volumeCard.setValue(snap.volumeText)
|
||
wifiCard.setValue(snap.wifiText)
|
||
deviceNameLab.text = snap.deviceText
|
||
storageLab.text = snap.storageText
|
||
storageBar.progress = Float(snap.storageRatio)
|
||
updateBrandLogo(urlString: snap.brandIconURL)
|
||
screenSection.setSummary(snap.screenTodayText)
|
||
unlockSection.setSummary(snap.appUsageTodayText)
|
||
screenSection.setBars(snap.screenBars)
|
||
unlockSection.setBars(snap.appUsageBars)
|
||
}
|
||
|
||
func setPrivilegeDenied(_ denied: Bool) {
|
||
let title = "TA未开启设置"
|
||
let message = "无法查看手机状态"
|
||
let action = "提醒TA"
|
||
statusDeniedOverlay.configure(
|
||
title: title,
|
||
message: message,
|
||
actionTitle: action,
|
||
imageName: "PrivilegeDenied/report_status"
|
||
)
|
||
statusDeniedOverlay.onAction = { [weak self] in self?.onRemind?() }
|
||
statusDeniedOverlay.setVisible(denied)
|
||
if denied { statusCard.bringSubviewToFront(statusDeniedOverlay) }
|
||
screenSection.setDenied(denied, imageName: "PrivilegeDenied/report_screen", onRemind: { [weak self] in
|
||
self?.onRemind?()
|
||
})
|
||
unlockSection.setDenied(denied, imageName: "PrivilegeDenied/report_usage", onRemind: { [weak self] in
|
||
self?.onRemind?()
|
||
})
|
||
}
|
||
|
||
private func updateBrandLogo(urlString: String) {
|
||
brandLogoURL = urlString
|
||
brandLogoImage.kf.cancelDownloadTask()
|
||
brandLogoImage.image = nil
|
||
brandLogoImage.isHidden = true
|
||
|
||
guard let url = URL(string: urlString), !urlString.isEmpty else {
|
||
return
|
||
}
|
||
|
||
brandLogoImage.kf.setImage(with: url) { [weak self] result in
|
||
guard let self, self.brandLogoURL == urlString else { return }
|
||
switch result {
|
||
case .success:
|
||
self.brandLogoImage.isHidden = false
|
||
case .failure:
|
||
self.brandLogoImage.image = nil
|
||
self.brandLogoImage.isHidden = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private extension UIView {
|
||
func applyPhoneReportCardStyle(cornerRadius: CGFloat) {
|
||
layer.cornerRadius = cornerRadius
|
||
clipsToBounds = false
|
||
layer.masksToBounds = false
|
||
layer.shadowColor = UIColor(hexStr: "#E5E5E5", alpha: 0.3).cgColor
|
||
layer.shadowOffset = CGSize(width: 0, height: 0)
|
||
layer.shadowOpacity = 1
|
||
layer.shadowRadius = 5
|
||
}
|
||
}
|
||
|
||
// MARK: - Subviews
|
||
|
||
final class PhoneBatteryRingView: UIView {
|
||
private let track = CAShapeLayer()
|
||
private let progress = CAShapeLayer()
|
||
private let valueLab = UILabel()
|
||
private let bolt = UIImageView(image: UIImage(named: "Home/report_bolt"))
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
track.fillColor = UIColor.clear.cgColor
|
||
track.strokeColor = UIColor(hexStr: "#DEFFD8").cgColor
|
||
track.lineWidth = 11
|
||
layer.addSublayer(track)
|
||
progress.fillColor = UIColor.clear.cgColor
|
||
progress.strokeColor = UIColor(hexStr: "#27DB4B").cgColor
|
||
progress.lineWidth = 11
|
||
progress.lineCap = .round
|
||
progress.strokeEnd = 0
|
||
layer.addSublayer(progress)
|
||
|
||
bolt.contentMode = .scaleAspectFit
|
||
addSubview(bolt)
|
||
valueLab.font = .systemFont(ofSize: 16, weight: .heavy)
|
||
valueLab.textColor = UIColor(hexStr: "#293445")
|
||
valueLab.textAlignment = .center
|
||
addSubview(valueLab)
|
||
valueLab.layoutChain.centerX().centerY()
|
||
bolt.layoutChain
|
||
.centerY(valueLab)
|
||
.rightToLeftOfView(valueLab, offset: -2)
|
||
.width(12)
|
||
.height(14)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||
let radius = min(bounds.width, bounds.height) / 2 - 6
|
||
let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: -.pi / 2, endAngle: 1.5 * .pi, clockwise: true)
|
||
track.path = path.cgPath
|
||
progress.path = path.cgPath
|
||
}
|
||
|
||
func configure(percent: Int?, text: String, isCharging: Bool = false) {
|
||
valueLab.text = text
|
||
bolt.isHidden = !isCharging
|
||
progress.strokeEnd = CGFloat(max(0, min(100, percent ?? 0))) / 100
|
||
}
|
||
}
|
||
|
||
final class PhoneStatusMiniCard: UIView {
|
||
private let icon = UIImageView()
|
||
private let titleLab = UILabel()
|
||
private let valueLab = UILabel()
|
||
|
||
init(iconName: String, title: String) {
|
||
super.init(frame: .zero)
|
||
backgroundColor = UIColor(hexStr: "#FAFAFA")
|
||
cornerRadius = 12
|
||
icon.image = UIImage(named: iconName)
|
||
icon.contentMode = .scaleAspectFit
|
||
addSubview(icon)
|
||
icon.layoutChain.top(7).left(5).width(16).height(16)
|
||
titleLab.text = title
|
||
titleLab.font = .systemFont(ofSize: 10, weight: .medium)
|
||
titleLab.textColor = UIColor(hexStr: "#AAAAAA")
|
||
titleLab.textAlignment = .center
|
||
addSubview(titleLab)
|
||
titleLab.layoutChain.leftToRightOfView(icon, offset: 4).right(5, relation: .greaterThanOrEqual).centerY(icon)
|
||
valueLab.font = .systemFont(ofSize: 16, weight: .medium)
|
||
valueLab.textColor = UIColor(hexStr: "#293445")
|
||
valueLab.textAlignment = .center
|
||
addSubview(valueLab)
|
||
valueLab.layoutChain.topToBottomOfView(icon, offset: 10).edgesHorzontal(2)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
func setValue(_ text: String) {
|
||
valueLab.text = text
|
||
}
|
||
}
|
||
|
||
final class PhoneReportChartSectionView: UIView {
|
||
let summaryPrefix: String
|
||
private let titleLab = UILabel()
|
||
private let summaryPrefixLab = UILabel()
|
||
private let summaryLab = UILabel()
|
||
private let chart = PhoneReportBarChartView()
|
||
private let summaryColor: UIColor
|
||
private let barGradient: (UIColor, UIColor)
|
||
private let highlightGradient: (UIColor, UIColor)
|
||
private let tipColor: UIColor
|
||
private let tipArrowImageName: String
|
||
private let valueSuffix: String
|
||
private let card = UIView()
|
||
private let deniedOverlay = PrivilegeDeniedOverlay()
|
||
|
||
init(
|
||
title: String,
|
||
iconName: String,
|
||
summaryPrefix: String,
|
||
summaryColor: UIColor,
|
||
barGradient: (UIColor, UIColor),
|
||
highlightGradient: (UIColor, UIColor),
|
||
tipColor: UIColor,
|
||
tipArrowImageName: String,
|
||
valueSuffix: String = ""
|
||
) {
|
||
self.summaryPrefix = summaryPrefix
|
||
self.summaryColor = summaryColor
|
||
self.barGradient = barGradient
|
||
self.highlightGradient = highlightGradient
|
||
self.tipColor = tipColor
|
||
self.tipArrowImageName = tipArrowImageName
|
||
self.valueSuffix = valueSuffix
|
||
super.init(frame: .zero)
|
||
let titleIcon = UIImageView(image: UIImage(named: iconName))
|
||
titleIcon.contentMode = .scaleAspectFit
|
||
addSubview(titleIcon)
|
||
titleIcon.layoutChain.top().left().width(18).height(18)
|
||
titleLab.text = title
|
||
titleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||
titleLab.textColor = UIColor(hexStr: "#293445")
|
||
addSubview(titleLab)
|
||
titleLab.layoutChain.centerY(titleIcon).leftToRightOfView(titleIcon, offset: 6)
|
||
|
||
card.backgroundColor = .white
|
||
card.applyPhoneReportCardStyle(cornerRadius: 20)
|
||
addSubview(card)
|
||
card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180)
|
||
card.addSubview(deniedOverlay)
|
||
deniedOverlay.layoutChain.edges()
|
||
deniedOverlay.layer.cornerRadius = 20
|
||
|
||
summaryPrefixLab.text = summaryPrefix
|
||
summaryPrefixLab.font = .systemFont(ofSize: 12, weight: .medium)
|
||
summaryPrefixLab.textColor = UIColor(hexStr: "#293445")
|
||
summaryPrefixLab.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
card.addSubview(summaryPrefixLab)
|
||
summaryPrefixLab.layoutChain.top(14).left(14)
|
||
|
||
summaryLab.font = .systemFont(ofSize: 12, weight: .bold)
|
||
summaryLab.textColor = summaryColor
|
||
summaryLab.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||
card.addSubview(summaryLab)
|
||
summaryLab.layoutChain
|
||
.centerY(summaryPrefixLab)
|
||
.leftToRightOfView(summaryPrefixLab, offset: 4)
|
||
.right(14, relation: .greaterThanOrEqual)
|
||
|
||
card.addSubview(chart)
|
||
chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).bottom(14)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
func setDenied(_ denied: Bool, imageName: String, onRemind: (() -> Void)?) {
|
||
deniedOverlay.configure(
|
||
title: "TA未开启设置",
|
||
message: "无法查看手机状态",
|
||
actionTitle: "提醒TA",
|
||
imageName: imageName
|
||
)
|
||
deniedOverlay.onAction = onRemind
|
||
deniedOverlay.setVisible(denied)
|
||
if denied { card.bringSubviewToFront(deniedOverlay) }
|
||
}
|
||
|
||
func setSummary(_ text: String) {
|
||
summaryLab.text = text
|
||
}
|
||
|
||
func setBars(_ bars: [PhoneReportDetailViewModel.ChartBar]) {
|
||
chart.configure(
|
||
bars: bars,
|
||
barGradient: barGradient,
|
||
highlightGradient: highlightGradient,
|
||
tipColor: tipColor,
|
||
tipArrowImageName: tipArrowImageName,
|
||
suffix: valueSuffix
|
||
)
|
||
}
|
||
}
|
||
|
||
final class PhoneReportBarChartView: UIView {
|
||
private var bars: [PhoneReportDetailViewModel.ChartBar] = []
|
||
private var barGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue)
|
||
private var highlightGradient: (UIColor, UIColor) = (.systemBlue, .systemBlue)
|
||
private var tipColor: UIColor = .systemBlue
|
||
private var suffix = ""
|
||
private var barLayers: [CALayer] = []
|
||
private var labelViews: [UILabel] = []
|
||
private var barFrames: [CGRect] = []
|
||
private var selectedIndex: Int?
|
||
private var barSpacing: CGFloat = 0
|
||
private var baselineY: CGFloat = 0
|
||
private let tipLab = UILabel()
|
||
private let tipArrowView = UIImageView()
|
||
private let baseline = UIView()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
clipsToBounds = false
|
||
isUserInteractionEnabled = true
|
||
baseline.backgroundColor = UIColor(hexStr: "#F2F2F2")
|
||
addSubview(baseline)
|
||
tipLab.font = .systemFont(ofSize: 11, weight: .bold)
|
||
tipLab.textColor = .white
|
||
tipLab.textAlignment = .center
|
||
tipLab.cornerRadius = 6
|
||
tipLab.clipsToBounds = true
|
||
tipLab.isHidden = true
|
||
tipLab.isUserInteractionEnabled = false
|
||
addSubview(tipLab)
|
||
tipArrowView.contentMode = .scaleAspectFit
|
||
tipArrowView.isHidden = true
|
||
tipArrowView.isUserInteractionEnabled = false
|
||
addSubview(tipArrowView)
|
||
|
||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||
addGestureRecognizer(tap)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
func configure(
|
||
bars: [PhoneReportDetailViewModel.ChartBar],
|
||
barGradient: (UIColor, UIColor),
|
||
highlightGradient: (UIColor, UIColor),
|
||
tipColor: UIColor,
|
||
tipArrowImageName: String,
|
||
suffix: String
|
||
) {
|
||
let previous = self.bars
|
||
self.bars = bars
|
||
self.barGradient = barGradient
|
||
self.highlightGradient = highlightGradient
|
||
self.tipColor = tipColor
|
||
tipArrowView.image = UIImage(named: tipArrowImageName)
|
||
self.suffix = suffix
|
||
let dataChanged = previous.count != bars.count
|
||
|| zip(previous, bars).contains { $0.label != $1.label || $0.value != $1.value }
|
||
if dataChanged || selectedIndex == nil {
|
||
selectedIndex = bars.firstIndex(where: \.isToday) ?? (bars.isEmpty ? nil : bars.count - 1)
|
||
}
|
||
setNeedsLayout()
|
||
}
|
||
|
||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||
let point = gesture.location(in: self)
|
||
guard !barFrames.isEmpty else { return }
|
||
for (i, frame) in barFrames.enumerated() {
|
||
let hitX = frame.minX - barSpacing / 2
|
||
let hitW = frame.width + barSpacing
|
||
let hit = CGRect(x: hitX, y: 0, width: hitW, height: max(baselineY, bounds.height))
|
||
if hit.contains(point) {
|
||
guard selectedIndex != i else { return }
|
||
selectedIndex = i
|
||
setNeedsLayout()
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
barLayers.forEach { $0.removeFromSuperlayer() }
|
||
barLayers.removeAll()
|
||
labelViews.forEach { $0.removeFromSuperview() }
|
||
labelViews.removeAll()
|
||
barFrames.removeAll()
|
||
tipLab.isHidden = true
|
||
tipArrowView.isHidden = true
|
||
guard !bars.isEmpty else {
|
||
baseline.isHidden = true
|
||
return
|
||
}
|
||
baseline.isHidden = false
|
||
|
||
let maxVal = max(bars.map(\.value).max() ?? 1, 1)
|
||
let count = bars.count
|
||
let labelH: CGFloat = 16
|
||
let tipVerticalPadding: CGFloat = 8
|
||
let tipArrowHeight: CGFloat = 5
|
||
let tipArrowBarSpacing: CGFloat = 6
|
||
let tipHeight = ceil(tipLab.font.lineHeight) + tipVerticalPadding
|
||
let chartH = max(4, bounds.height - labelH - tipHeight - tipArrowHeight - tipArrowBarSpacing)
|
||
let barW: CGFloat = 16
|
||
barSpacing = count > 1
|
||
? max(0, (bounds.width - barW * CGFloat(count)) / CGFloat(count - 1))
|
||
: 0
|
||
baselineY = bounds.height - labelH
|
||
baseline.frame = CGRect(x: 0, y: baselineY - 0.5, width: bounds.width, height: 1)
|
||
|
||
if selectedIndex == nil || selectedIndex! >= count {
|
||
selectedIndex = bars.firstIndex(where: \.isToday) ?? count - 1
|
||
}
|
||
|
||
for (i, bar) in bars.enumerated() {
|
||
let ratio = CGFloat(bar.value) / CGFloat(maxVal)
|
||
let h = max(4, chartH * max(0.05, ratio))
|
||
let x = CGFloat(i) * (barW + barSpacing)
|
||
let y = baselineY - h
|
||
let barFrame = CGRect(x: x, y: y, width: barW, height: h)
|
||
barFrames.append(barFrame)
|
||
|
||
let isSelected = i == selectedIndex
|
||
let gradient = CAGradientLayer()
|
||
gradient.frame = barFrame
|
||
let colors = isSelected ? highlightGradient : barGradient
|
||
gradient.colors = [colors.0.cgColor, colors.1.cgColor]
|
||
gradient.startPoint = CGPoint(x: 0.5, y: 0)
|
||
gradient.endPoint = CGPoint(x: 0.5, y: 1)
|
||
gradient.cornerRadius = 4
|
||
gradient.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||
layer.insertSublayer(gradient, below: tipLab.layer)
|
||
barLayers.append(gradient)
|
||
|
||
let lab = UILabel()
|
||
lab.text = bar.label
|
||
lab.font = .systemFont(ofSize: 10, weight: .medium)
|
||
lab.textColor = UIColor(hexStr: "#9CA3AF")
|
||
lab.textAlignment = .center
|
||
lab.frame = CGRect(x: x - 10, y: bounds.height - labelH, width: barW + 20, height: labelH)
|
||
insertSubview(lab, belowSubview: tipLab)
|
||
labelViews.append(lab)
|
||
|
||
if isSelected {
|
||
tipLab.text = tipText(for: bar)
|
||
tipLab.backgroundColor = tipColor
|
||
tipLab.sizeToFit()
|
||
let tipW = tipLab.bounds.width + 5 + 5
|
||
// 与柱子水平居中,允许超出 chart 左右边界
|
||
tipLab.frame = CGRect(
|
||
x: x + barW / 2 - tipW / 2,
|
||
y: max(0, y - tipHeight - tipArrowHeight - tipArrowBarSpacing),
|
||
width: tipW,
|
||
height: tipHeight
|
||
)
|
||
tipLab.isHidden = false
|
||
tipArrowView.frame = CGRect(
|
||
x: x + barW / 2 - 5,
|
||
y: tipLab.frame.maxY,
|
||
width: 10,
|
||
height: tipArrowHeight
|
||
)
|
||
tipArrowView.isHidden = false
|
||
}
|
||
}
|
||
bringSubviewToFront(tipLab)
|
||
bringSubviewToFront(tipArrowView)
|
||
}
|
||
|
||
private func tipText(for bar: PhoneReportDetailViewModel.ChartBar) -> String {
|
||
if suffix == "次" {
|
||
return "\(bar.value)次"
|
||
}
|
||
let hours = bar.value / 3600
|
||
let mins = (bar.value % 3600) / 60
|
||
if hours > 0 {
|
||
return mins > 0 ? "\(hours)小时\(mins)分" : "\(hours)小时"
|
||
}
|
||
return "\(mins)分钟"
|
||
}
|
||
}
|
||
|
||
private final class PhoneReportMemberTrailingFadeView: UIView {
|
||
override class var layerClass: AnyClass { CAGradientLayer.self }
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
isUserInteractionEnabled = false
|
||
let gradientLayer = layer as! CAGradientLayer
|
||
gradientLayer.colors = [
|
||
UIColor.white.withAlphaComponent(0).cgColor,
|
||
UIColor.white.withAlphaComponent(0.88).cgColor,
|
||
UIColor.white.cgColor
|
||
]
|
||
gradientLayer.locations = [0, 0.7, 1]
|
||
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
|
||
gradientLayer.cornerRadius = 20
|
||
gradientLayer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner]
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
}
|