619 lines
23 KiB
Swift
619 lines
23 KiB
Swift
//
|
||
// PhoneReportDetailView.swift
|
||
// QuickLocation
|
||
//
|
||
|
||
import UIKit
|
||
|
||
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 = .white
|
||
cv.showsHorizontalScrollIndicator = false
|
||
cv.contentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||
cv.register(GroupMemberListCell.self)
|
||
cv.applyPhoneReportCardStyle(cornerRadius: 20)
|
||
return cv
|
||
}()
|
||
|
||
let scrollView = UIScrollView()
|
||
let contentView = UIView()
|
||
|
||
// MARK: Status
|
||
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"))
|
||
|
||
// MARK: Charts
|
||
let screenSection = PhoneReportChartSectionView(
|
||
title: "屏幕使用时间",
|
||
summaryPrefix: "今日使用时长",
|
||
summaryColor: UIColor(hexStr: "#00ADFE"),
|
||
barGradient: (UIColor(hexStr: "#C0EAFF"), UIColor(hexStr: "#ECF9FF")),
|
||
highlightGradient: (UIColor(hexStr: "#16B3FF"), UIColor(hexStr: "#9EDFFF")),
|
||
tipColor: UIColor(hexStr: "#20B6FF")
|
||
)
|
||
let unlockSection = PhoneReportChartSectionView(
|
||
title: "手机使用次数",
|
||
summaryPrefix: "今日使用次数",
|
||
summaryColor: UIColor(hexStr: "#68C025"),
|
||
barGradient: (UIColor(hexStr: "#DDFFA1"), UIColor(hexStr: "#F2FFDC")),
|
||
highlightGradient: (UIColor(hexStr: "#AAF52A"), UIColor(hexStr: "#E8FFC1")),
|
||
tipColor: UIColor(hexStr: "#86E73D"),
|
||
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)
|
||
|
||
contentView.addSubview(memberCV)
|
||
memberCV.layoutChain
|
||
.top(8)
|
||
.edgesHorzontal(15)
|
||
.height(100)
|
||
|
||
statusTitleLab.text = "手机状态"
|
||
statusTitleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||
statusTitleLab.textColor = UIColor(hexStr: "#293445")
|
||
contentView.addSubview(statusTitleLab)
|
||
statusTitleLab.layoutChain.topToBottomOfView(memberCV, offset: 12).left(15)
|
||
|
||
statusCard.backgroundColor = .white
|
||
statusCard.applyPhoneReportCardStyle(cornerRadius: 20)
|
||
contentView.addSubview(statusCard)
|
||
statusCard.layoutChain
|
||
.topToBottomOfView(statusTitleLab, offset: 10)
|
||
.edgesHorzontal(15)
|
||
.height(175)
|
||
|
||
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: "#F7FBFF")
|
||
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)
|
||
|
||
contentView.addSubview(screenSection)
|
||
screenSection.layoutChain
|
||
.topToBottomOfView(statusCard, offset: 16)
|
||
.edgesHorzontal(15)
|
||
|
||
contentView.addSubview(unlockSection)
|
||
unlockSection.layoutChain
|
||
.topToBottomOfView(screenSection, offset: 16)
|
||
.edgesHorzontal(15)
|
||
.bottom(24)
|
||
}
|
||
|
||
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)
|
||
screenSection.setSummary(snap.screenTodayText)
|
||
unlockSection.setSummary("\(snap.unlockTodayText)次")
|
||
screenSection.setBars(snap.screenBars)
|
||
unlockSection.setBars(snap.unlockBars)
|
||
}
|
||
}
|
||
|
||
private extension UIView {
|
||
func applyPhoneReportCardStyle(cornerRadius: CGFloat) {
|
||
layer.cornerRadius = cornerRadius
|
||
clipsToBounds = false
|
||
layer.masksToBounds = false
|
||
layer.shadowColor = UIColor(hexStr: "#0F2846", alpha: 0.1).cgColor
|
||
layer.shadowOffset = CGSize(width: 0, height: 2)
|
||
layer.shadowOpacity = 1
|
||
layer.shadowRadius = 10
|
||
}
|
||
}
|
||
|
||
// MARK: - Subviews
|
||
|
||
final class PhoneBatteryRingView: UIView {
|
||
private let track = CAShapeLayer()
|
||
private let progress = CAShapeLayer()
|
||
private let valueLab = UILabel()
|
||
private let bolt = UIImageView(image: UIImage(systemName: "bolt.fill"))
|
||
|
||
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.tintColor = UIColor(hexStr: "#27DB4B")
|
||
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)
|
||
}
|
||
|
||
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: "#F7FBFF")
|
||
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 dayBtn = UIButton(type: .system)
|
||
private let weekBtn = UIButton(type: .system)
|
||
private let chart = PhoneReportBarChartView()
|
||
private let summaryColor: UIColor
|
||
private let barGradient: (UIColor, UIColor)
|
||
private let highlightGradient: (UIColor, UIColor)
|
||
private let tipColor: UIColor
|
||
private let valueSuffix: String
|
||
|
||
var onModeChanged: ((PhoneReportDetailViewModel.ChartMode) -> Void)?
|
||
|
||
init(
|
||
title: String,
|
||
summaryPrefix: String,
|
||
summaryColor: UIColor,
|
||
barGradient: (UIColor, UIColor),
|
||
highlightGradient: (UIColor, UIColor),
|
||
tipColor: UIColor,
|
||
valueSuffix: String = ""
|
||
) {
|
||
self.summaryPrefix = summaryPrefix
|
||
self.summaryColor = summaryColor
|
||
self.barGradient = barGradient
|
||
self.highlightGradient = highlightGradient
|
||
self.tipColor = tipColor
|
||
self.valueSuffix = valueSuffix
|
||
super.init(frame: .zero)
|
||
titleLab.text = title
|
||
titleLab.font = .systemFont(ofSize: 16, weight: .bold)
|
||
titleLab.textColor = UIColor(hexStr: "#293445")
|
||
addSubview(titleLab)
|
||
titleLab.layoutChain.top().left()
|
||
|
||
let card = UIView()
|
||
card.backgroundColor = .white
|
||
card.applyPhoneReportCardStyle(cornerRadius: 20)
|
||
addSubview(card)
|
||
card.layoutChain.topToBottomOfView(titleLab, offset: 10).edgesHorzontal().bottom().height(180)
|
||
|
||
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)
|
||
|
||
configureToggle(dayBtn, title: "天", selected: true)
|
||
configureToggle(weekBtn, title: "周", selected: false)
|
||
let toggle = UIStackView(arrangedSubviews: [weekBtn, dayBtn])
|
||
toggle.axis = .horizontal
|
||
toggle.spacing = 0
|
||
|
||
let toggleBg = UIView()
|
||
toggleBg.backgroundColor = UIColor(hexStr: "#EAEAEA")
|
||
toggleBg.layer.cornerRadius = 16
|
||
toggleBg.clipsToBounds = true
|
||
toggleBg.addSubview(toggle)
|
||
toggle.layoutChain.edges(UIEdgeInsets(top: 3, left: 3, bottom: 3, right: 3))
|
||
|
||
card.addSubview(toggleBg)
|
||
toggleBg.layoutChain.centerY(summaryPrefixLab).right(14).height(32)
|
||
|
||
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)
|
||
.rightToLeftOfView(toggleBg, offset: 8, relation: .lessThanOrEqual)
|
||
|
||
dayBtn.addTarget(self, action: #selector(tapDay), for: .touchUpInside)
|
||
weekBtn.addTarget(self, action: #selector(tapWeek), for: .touchUpInside)
|
||
|
||
card.addSubview(chart)
|
||
chart.layoutChain.topToBottomOfView(summaryPrefixLab, offset: 16).edgesHorzontal(25).bottom(14)
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
private func configureToggle(_ btn: UIButton, title: String, selected: Bool) {
|
||
btn.setTitle(title, for: .normal)
|
||
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .bold)
|
||
btn.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||
applyToggle(btn, selected: selected)
|
||
}
|
||
|
||
private func applyToggle(_ btn: UIButton, selected: Bool) {
|
||
btn.backgroundColor = selected ? .white : .clear
|
||
btn.setTitleColor(selected ? UIColor(hexStr: "#293445") : UIColor(hexStr: "#9CA3AF"), for: .normal)
|
||
btn.cornerRadius = 12
|
||
}
|
||
|
||
@objc private func tapDay() {
|
||
applyToggle(dayBtn, selected: true)
|
||
applyToggle(weekBtn, selected: false)
|
||
onModeChanged?(.day)
|
||
}
|
||
|
||
@objc private func tapWeek() {
|
||
applyToggle(dayBtn, selected: false)
|
||
applyToggle(weekBtn, selected: true)
|
||
onModeChanged?(.week)
|
||
}
|
||
|
||
func setSummary(_ text: String) {
|
||
summaryLab.text = text
|
||
}
|
||
|
||
func setBars(_ bars: [PhoneReportDetailViewModel.ChartBar]) {
|
||
chart.configure(
|
||
bars: bars,
|
||
barGradient: barGradient,
|
||
highlightGradient: highlightGradient,
|
||
tipColor: tipColor,
|
||
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 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)
|
||
|
||
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,
|
||
suffix: String
|
||
) {
|
||
let previous = self.bars
|
||
self.bars = bars
|
||
self.barGradient = barGradient
|
||
self.highlightGradient = highlightGradient
|
||
self.tipColor = tipColor
|
||
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
|
||
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 chartH = bounds.height - labelH - 24
|
||
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
|
||
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
|
||
let tipH = tipLab.bounds.height + 4 + 4
|
||
// 与柱子水平居中,允许超出 chart 左右边界
|
||
tipLab.frame = CGRect(
|
||
x: x + barW / 2 - tipW / 2,
|
||
y: max(0, y - tipH - 4),
|
||
width: tipW,
|
||
height: tipH
|
||
)
|
||
tipLab.isHidden = false
|
||
}
|
||
}
|
||
bringSubviewToFront(tipLab)
|
||
}
|
||
|
||
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)h \(mins)m" : "\(hours)h"
|
||
}
|
||
return "\(mins)m"
|
||
}
|
||
}
|