mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1412 lines
61 KiB
Swift
1412 lines
61 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import ComponentFlow
|
|
import SwiftSignalKit
|
|
import ViewControllerComponent
|
|
import ComponentDisplayAdapters
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import TelegramCore
|
|
import MultilineTextComponent
|
|
import EmojiStatusComponent
|
|
|
|
private func processChartData(data: PieChartComponent.ChartData) -> PieChartComponent.ChartData {
|
|
var data = data
|
|
|
|
let minValue: Double = 0.01
|
|
|
|
var totalSum: CGFloat = 0.0
|
|
for i in 0 ..< data.items.count {
|
|
if data.items[i].value > 0.00001 {
|
|
data.items[i].value = max(data.items[i].value, minValue)
|
|
}
|
|
totalSum += data.items[i].value
|
|
}
|
|
|
|
var hasOneItem = false
|
|
for i in 0 ..< data.items.count {
|
|
if data.items[i].value != 0 && totalSum == data.items[i].value {
|
|
data.items[i].value = 1.0
|
|
hasOneItem = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasOneItem {
|
|
if abs(totalSum - 1.0) > 0.0001 {
|
|
let deltaValue = totalSum - 1.0
|
|
|
|
var availableSum: Double = 0.0
|
|
for i in 0 ..< data.items.count {
|
|
let itemValue = data.items[i].value
|
|
let availableItemValue = max(0.0, itemValue - minValue)
|
|
if availableItemValue > 0.0 {
|
|
availableSum += availableItemValue
|
|
}
|
|
}
|
|
totalSum = 0.0
|
|
let itemFraction = deltaValue / availableSum
|
|
for i in 0 ..< data.items.count {
|
|
let itemValue = data.items[i].value
|
|
let availableItemValue = max(0.0, itemValue - minValue)
|
|
if availableItemValue > 0.0 {
|
|
let itemDelta = availableItemValue * itemFraction
|
|
data.items[i].value -= itemDelta
|
|
}
|
|
totalSum += data.items[i].value
|
|
}
|
|
}
|
|
|
|
if totalSum > 0.0 && totalSum < 1.0 - 0.0001 {
|
|
for i in 0 ..< data.items.count {
|
|
data.items[i].value /= totalSum
|
|
}
|
|
}
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
private let chartLabelFont = Font.with(size: 16.0, design: .round, weight: .semibold)
|
|
|
|
private final class ChartSelectionTooltip: Component {
|
|
let theme: PresentationTheme
|
|
let fractionText: String
|
|
let title: String
|
|
let sizeText: String
|
|
|
|
init(
|
|
theme: PresentationTheme,
|
|
fractionText: String,
|
|
title: String,
|
|
sizeText: String
|
|
) {
|
|
self.theme = theme
|
|
self.fractionText = fractionText
|
|
self.title = title
|
|
self.sizeText = sizeText
|
|
}
|
|
|
|
static func ==(lhs: ChartSelectionTooltip, rhs: ChartSelectionTooltip) -> Bool {
|
|
return true
|
|
}
|
|
|
|
final class View: UIView {
|
|
private let backgroundView: BlurredBackgroundView
|
|
private let title = ComponentView<Empty>()
|
|
|
|
override init(frame: CGRect) {
|
|
self.backgroundView = BlurredBackgroundView(color: .clear, enableBlur: true)
|
|
|
|
self.backgroundView.layer.shadowOpacity = 0.12
|
|
self.backgroundView.layer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor
|
|
self.backgroundView.layer.shadowOffset = CGSize(width: 0.0, height: 2.0)
|
|
self.backgroundView.layer.shadowRadius = 8.0
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.backgroundView)
|
|
}
|
|
|
|
required init(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
func update(component: ChartSelectionTooltip, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
|
let sideInset: CGFloat = 10.0
|
|
let height: CGFloat = 24.0
|
|
|
|
let text = NSMutableAttributedString()
|
|
text.append(NSAttributedString(string: component.fractionText + " ", font: Font.semibold(12.0), textColor: component.theme.list.itemPrimaryTextColor))
|
|
text.append(NSAttributedString(string: component.title + " ", font: Font.regular(12.0), textColor: component.theme.list.itemPrimaryTextColor))
|
|
text.append(NSAttributedString(string: component.sizeText, font: Font.semibold(12.0), textColor: component.theme.list.itemAccentColor))
|
|
|
|
let titleSize = self.title.update(
|
|
transition: transition,
|
|
component: AnyComponent(MultilineTextComponent(
|
|
text: .plain(text)
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0)
|
|
)
|
|
let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((height - titleSize.height) / 2.0)), size: titleSize)
|
|
if let titleView = self.title.view {
|
|
if titleView.superview == nil {
|
|
self.addSubview(titleView)
|
|
}
|
|
transition.setFrame(view: titleView, frame: titleFrame)
|
|
}
|
|
|
|
let size = CGSize(width: sideInset * 2.0 + titleSize.width, height: height)
|
|
|
|
transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: size))
|
|
self.backgroundView.updateColor(color: component.theme.list.plainBackgroundColor.withMultipliedAlpha(0.88), transition: .immediate)
|
|
self.backgroundView.update(size: size, cornerRadius: 10.0, transition: transition.containedViewLayoutTransition)
|
|
|
|
self.backgroundView.layer.shadowPath = UIBezierPath(roundedRect: self.backgroundView.bounds, cornerRadius: 10.0).cgPath
|
|
|
|
return size
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|
|
|
|
private final class ChartLabel: UIView {
|
|
private let label: ImmediateTextView
|
|
private var currentText: String?
|
|
|
|
override init(frame: CGRect) {
|
|
self.label = ImmediateTextView()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.label)
|
|
}
|
|
|
|
required init(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
func update(text: String) -> CGSize {
|
|
if self.currentText == text {
|
|
return self.label.bounds.size
|
|
}
|
|
|
|
var snapshotView: UIView?
|
|
if self.currentText != nil {
|
|
snapshotView = self.label.snapshotView(afterScreenUpdates: false)
|
|
snapshotView?.frame = self.label.frame
|
|
}
|
|
|
|
self.currentText = text
|
|
self.label.attributedText = NSAttributedString(string: text, font: chartLabelFont, textColor: .white)
|
|
let size = self.label.updateLayout(CGSize(width: 100.0, height: 100.0))
|
|
self.label.frame = CGRect(origin: CGPoint(x: floor(-size.width * 0.5), y: floor(-size.height * 0.5)), size: size)
|
|
|
|
if let snapshotView {
|
|
self.addSubview(snapshotView)
|
|
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
|
snapshotView?.removeFromSuperview()
|
|
})
|
|
snapshotView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
|
|
self.label.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
self.label.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
|
|
}
|
|
|
|
return size
|
|
}
|
|
}
|
|
|
|
final class PieChartComponent: Component {
|
|
struct ChartData: Equatable {
|
|
struct Item: Equatable {
|
|
var id: AnyHashable
|
|
var displayValue: Double
|
|
var displaySize: Int64
|
|
var value: Double
|
|
var color: UIColor
|
|
var particle: String?
|
|
var title: String
|
|
var mergeable: Bool
|
|
var mergeFactor: CGFloat
|
|
|
|
init(id: AnyHashable, displayValue: Double, displaySize: Int64, value: Double, color: UIColor, particle: String?, title: String, mergeable: Bool, mergeFactor: CGFloat) {
|
|
self.id = id
|
|
self.displayValue = displayValue
|
|
self.displaySize = displaySize
|
|
self.value = value
|
|
self.color = color
|
|
self.particle = particle
|
|
self.title = title
|
|
self.mergeable = mergeable
|
|
self.mergeFactor = mergeFactor
|
|
}
|
|
}
|
|
|
|
var items: [Item]
|
|
|
|
init(items: [Item]) {
|
|
self.items = items
|
|
}
|
|
}
|
|
|
|
let theme: PresentationTheme
|
|
let strings: PresentationStrings
|
|
let emptyColor: UIColor
|
|
let chartData: ChartData
|
|
|
|
init(
|
|
theme: PresentationTheme,
|
|
strings: PresentationStrings,
|
|
emptyColor: UIColor,
|
|
chartData: ChartData
|
|
) {
|
|
self.theme = theme
|
|
self.strings = strings
|
|
self.emptyColor = emptyColor
|
|
self.chartData = chartData
|
|
}
|
|
|
|
static func ==(lhs: PieChartComponent, rhs: PieChartComponent) -> Bool {
|
|
if lhs.theme !== rhs.theme {
|
|
return false
|
|
}
|
|
if lhs.strings !== rhs.strings {
|
|
return false
|
|
}
|
|
if lhs.emptyColor != rhs.emptyColor {
|
|
return false
|
|
}
|
|
if lhs.chartData != rhs.chartData {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private struct CalculatedLabel {
|
|
var image: UIImage
|
|
var alpha: CGFloat
|
|
var angle: CGFloat
|
|
var radius: CGFloat
|
|
var scale: CGFloat
|
|
|
|
init(
|
|
image: UIImage,
|
|
alpha: CGFloat,
|
|
angle: CGFloat,
|
|
radius: CGFloat,
|
|
scale: CGFloat
|
|
) {
|
|
self.image = image
|
|
self.alpha = alpha
|
|
self.angle = angle
|
|
self.radius = radius
|
|
self.scale = scale
|
|
}
|
|
|
|
func interpolateTo(_ other: CalculatedLabel, amount: CGFloat) -> CalculatedLabel {
|
|
return CalculatedLabel(
|
|
image: other.image,
|
|
alpha: self.alpha.interpolate(to: other.alpha, amount: amount),
|
|
angle: self.angle.interpolate(to: other.angle, amount: amount),
|
|
radius: self.radius.interpolate(to: other.radius, amount: amount),
|
|
scale: self.scale.interpolate(to: other.scale, amount: amount)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct CalculatedSection {
|
|
var id: AnyHashable
|
|
var color: UIColor
|
|
var particle: String?
|
|
var title: String
|
|
var innerAngle: Range<CGFloat>
|
|
var outerAngle: Range<CGFloat>
|
|
var innerRadius: CGFloat
|
|
var outerRadius: CGFloat
|
|
var label: CalculatedLabel?
|
|
|
|
init(
|
|
id: AnyHashable,
|
|
color: UIColor,
|
|
particle: String?,
|
|
title: String,
|
|
innerAngle: Range<CGFloat>,
|
|
outerAngle: Range<CGFloat>,
|
|
innerRadius: CGFloat,
|
|
outerRadius: CGFloat,
|
|
label: CalculatedLabel?
|
|
) {
|
|
self.id = id
|
|
self.color = color
|
|
self.particle = particle
|
|
self.title = title
|
|
self.innerAngle = innerAngle
|
|
self.outerAngle = outerAngle
|
|
self.innerRadius = innerRadius
|
|
self.outerRadius = outerRadius
|
|
self.label = label
|
|
}
|
|
}
|
|
|
|
private struct ItemAngleData {
|
|
var angleValue: CGFloat
|
|
var startAngle: CGFloat
|
|
var endAngle: CGFloat
|
|
}
|
|
|
|
private struct CalculatedLayout {
|
|
var size: CGSize
|
|
var sections: [CalculatedSection]
|
|
var isEmpty: Bool
|
|
|
|
init(size: CGSize, sections: [CalculatedSection]) {
|
|
self.size = size
|
|
self.sections = sections
|
|
self.isEmpty = sections.isEmpty
|
|
}
|
|
|
|
init(interpolating start: CalculatedLayout, to end: CalculatedLayout, progress: CGFloat, size: CGSize) {
|
|
self.size = size
|
|
self.sections = []
|
|
self.isEmpty = end.isEmpty
|
|
|
|
for i in 0 ..< end.sections.count {
|
|
let right = end.sections[i]
|
|
|
|
if i < start.sections.count {
|
|
let left = start.sections[i]
|
|
let innerAngle: Range<CGFloat> = left.innerAngle.lowerBound.interpolate(to: right.innerAngle.lowerBound, amount: progress) ..< left.innerAngle.upperBound.interpolate(to: right.innerAngle.upperBound, amount: progress)
|
|
let outerAngle: Range<CGFloat> = left.outerAngle.lowerBound.interpolate(to: right.outerAngle.lowerBound, amount: progress) ..< left.outerAngle.upperBound.interpolate(to: right.outerAngle.upperBound, amount: progress)
|
|
|
|
var label: CalculatedLabel?
|
|
if let leftLabel = left.label, let rightLabel = right.label {
|
|
label = leftLabel.interpolateTo(rightLabel, amount: progress)
|
|
} else {
|
|
label = right.label
|
|
}
|
|
|
|
self.sections.append(CalculatedSection(
|
|
id: right.id,
|
|
color: left.color.interpolateTo(right.color, fraction: progress) ?? right.color,
|
|
particle: right.particle,
|
|
title: right.title,
|
|
innerAngle: innerAngle,
|
|
outerAngle: outerAngle,
|
|
innerRadius: left.innerRadius.interpolate(to: right.innerRadius, amount: progress),
|
|
outerRadius: left.outerRadius.interpolate(to: right.outerRadius, amount: progress),
|
|
label: label
|
|
))
|
|
} else {
|
|
self.sections.append(right)
|
|
}
|
|
}
|
|
}
|
|
|
|
init(size: CGSize, items: [ChartData.Item], selectedKey: AnyHashable?, isEmpty: Bool, emptyColor: UIColor) {
|
|
self.size = size
|
|
self.sections = []
|
|
self.isEmpty = isEmpty
|
|
|
|
if items.isEmpty {
|
|
return
|
|
}
|
|
|
|
let innerDiameter: CGFloat = isEmpty ? 90.0 : 100.0
|
|
let spacing: CGFloat = isEmpty ? -0.5 : 2.0
|
|
let innerAngleSpacing: CGFloat = spacing / (innerDiameter * 0.5)
|
|
|
|
var angles: [Double] = []
|
|
for i in 0 ..< items.count {
|
|
let item = items[i]
|
|
let angle = item.value * CGFloat.pi * 2.0
|
|
angles.append(angle)
|
|
}
|
|
|
|
let diameter: CGFloat = isEmpty ? (innerDiameter + 6.0 * 2.0) : 200.0
|
|
let reducedDiameter: CGFloat = floor(0.85 * diameter)
|
|
|
|
var anglesData: [ItemAngleData] = []
|
|
|
|
var startAngle: CGFloat = 0.0
|
|
for i in 0 ..< items.count {
|
|
let item = items[i]
|
|
|
|
let itemOuterDiameter: CGFloat
|
|
if let selectedKey {
|
|
if selectedKey == AnyHashable(item.id) {
|
|
itemOuterDiameter = diameter
|
|
} else {
|
|
itemOuterDiameter = reducedDiameter
|
|
}
|
|
} else {
|
|
itemOuterDiameter = diameter
|
|
}
|
|
|
|
let angleSpacing: CGFloat = spacing / (itemOuterDiameter * 0.5)
|
|
|
|
let angleValue: CGFloat = angles[i]
|
|
|
|
let beforeSpacingFraction: CGFloat = 1.0
|
|
let afterSpacingFraction: CGFloat = 1.0
|
|
|
|
let itemInnerAngleSpacing: CGFloat
|
|
let itemAngleSpacing: CGFloat
|
|
if abs(angleValue - CGFloat.pi * 2.0) <= 0.0001 {
|
|
itemInnerAngleSpacing = 0.0
|
|
itemAngleSpacing = 0.0
|
|
} else {
|
|
itemInnerAngleSpacing = innerAngleSpacing
|
|
itemAngleSpacing = angleSpacing
|
|
}
|
|
|
|
let innerStartAngle = startAngle + itemInnerAngleSpacing * 0.5
|
|
let arcInnerStartAngle = startAngle + itemInnerAngleSpacing * 0.5 * beforeSpacingFraction
|
|
|
|
var innerEndAngle = startAngle + angleValue - itemInnerAngleSpacing * 0.5
|
|
innerEndAngle = max(innerEndAngle, innerStartAngle)
|
|
var arcInnerEndAngle = startAngle + angleValue - itemInnerAngleSpacing * 0.5 * afterSpacingFraction
|
|
arcInnerEndAngle = max(arcInnerEndAngle, arcInnerStartAngle)
|
|
|
|
let outerStartAngle = startAngle + itemAngleSpacing * 0.5
|
|
let arcOuterStartAngle = startAngle + itemAngleSpacing * 0.5 * beforeSpacingFraction
|
|
var outerEndAngle = startAngle + angleValue - itemAngleSpacing * 0.5
|
|
outerEndAngle = max(outerEndAngle, outerStartAngle)
|
|
var arcOuterEndAngle = startAngle + angleValue - itemAngleSpacing * 0.5 * afterSpacingFraction
|
|
arcOuterEndAngle = max(arcOuterEndAngle, arcOuterStartAngle)
|
|
|
|
let itemColor: UIColor = isEmpty ? emptyColor : item.color
|
|
|
|
self.sections.append(CalculatedSection(
|
|
id: item.id,
|
|
color: itemColor,
|
|
particle: item.particle,
|
|
title: item.title,
|
|
innerAngle: arcInnerStartAngle ..< arcInnerEndAngle,
|
|
outerAngle: arcOuterStartAngle ..< arcOuterEndAngle,
|
|
innerRadius: innerDiameter * 0.5,
|
|
outerRadius: itemOuterDiameter * 0.5,
|
|
label: nil
|
|
))
|
|
|
|
startAngle += angleValue
|
|
|
|
anglesData.append(ItemAngleData(angleValue: angleValue, startAngle: innerStartAngle, endAngle: innerEndAngle))
|
|
}
|
|
|
|
for i in 0 ..< items.count {
|
|
let item = items[i]
|
|
|
|
var isDimmedBySelection = false
|
|
if let selectedKey {
|
|
if selectedKey == AnyHashable(item.id) {
|
|
} else {
|
|
isDimmedBySelection = true
|
|
}
|
|
}
|
|
|
|
self.updateLabel(
|
|
index: i,
|
|
displayValue: item.displayValue,
|
|
mergeFactor: item.mergeFactor,
|
|
innerAngle: self.sections[i].innerAngle,
|
|
outerAngle: self.sections[i].outerAngle,
|
|
innerRadius: self.sections[i].innerRadius,
|
|
outerRadius: self.sections[i].outerRadius,
|
|
isDimmedBySelection: isDimmedBySelection
|
|
)
|
|
}
|
|
}
|
|
|
|
private mutating func updateLabel(
|
|
index: Int,
|
|
displayValue: Double,
|
|
mergeFactor: CGFloat,
|
|
innerAngle: Range<CGFloat>,
|
|
outerAngle: Range<CGFloat>,
|
|
innerRadius: CGFloat,
|
|
outerRadius: CGFloat,
|
|
isDimmedBySelection: Bool
|
|
) {
|
|
let normalAlpha: CGFloat = isDimmedBySelection ? 0.0 : 1.0
|
|
|
|
let fractionValue: Double = floor(displayValue * 100.0 * 10.0) / 10.0
|
|
let fractionString: String
|
|
if displayValue == 0.0 {
|
|
fractionString = ""
|
|
} else if fractionValue < 0.1 {
|
|
fractionString = "<0.1%"
|
|
} else if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 {
|
|
fractionString = "\(Int(fractionValue))%"
|
|
} else {
|
|
fractionString = "\(fractionValue)%"
|
|
}
|
|
|
|
let labelString = NSAttributedString(string: fractionString, font: chartLabelFont, textColor: .white)
|
|
let labelBounds = labelString.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: [.usesLineFragmentOrigin], context: nil)
|
|
let labelSize = CGSize(width: ceil(labelBounds.width), height: ceil(labelBounds.height))
|
|
guard let labelImage = generateImage(labelSize, rotatedContext: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
UIGraphicsPushContext(context)
|
|
labelString.draw(in: labelBounds)
|
|
UIGraphicsPopContext()
|
|
}) else {
|
|
return
|
|
}
|
|
|
|
var resultLabel: CalculatedLabel?
|
|
|
|
if innerAngle.upperBound - innerAngle.lowerBound >= 0.001 {
|
|
for step in 0 ... 10 {
|
|
let stepFraction: CGFloat = CGFloat(step) / 10.0
|
|
let centerOffset: CGFloat = 0.5 * (1.0 - stepFraction) + 0.65 * stepFraction
|
|
|
|
let midAngle: CGFloat = (innerAngle.lowerBound + innerAngle.upperBound) * 0.5
|
|
let centerDistance: CGFloat = (innerRadius + (outerRadius - innerRadius) * centerOffset)
|
|
|
|
let relLabelCenter = CGPoint(
|
|
x: cos(midAngle) * centerDistance,
|
|
y: sin(midAngle) * centerDistance
|
|
)
|
|
|
|
func lineCircleIntersection(_ center: CGPoint, _ p1: CGPoint, _ p2: CGPoint, _ r: CGFloat) -> CGFloat {
|
|
let dx: CGFloat = p2.x - p1.x
|
|
let dy: CGFloat = p2.y - p1.y
|
|
let dr: CGFloat = sqrt(dx * dx + dy * dy)
|
|
let D: CGFloat = p1.x * p2.y - p2.x * p1.y
|
|
|
|
var minDistance: CGFloat = 10000.0
|
|
|
|
for i in 0 ..< 2 {
|
|
let signFactor: CGFloat = i == 0 ? 1.0 : (-1.0)
|
|
let dysign: CGFloat = dy < 0.0 ? -1.0 : 1.0
|
|
let ix: CGFloat = (D * dy + signFactor * dysign * dx * sqrt(r * r * dr * dr - D * D)) / (dr * dr)
|
|
let iy: CGFloat = (-D * dx + signFactor * abs(dy) * sqrt(r * r * dr * dr - D * D)) / (dr * dr)
|
|
let distance: CGFloat = sqrt(pow(ix - center.x, 2.0) + pow(iy - center.y, 2.0))
|
|
minDistance = min(minDistance, distance)
|
|
}
|
|
|
|
return minDistance
|
|
}
|
|
|
|
func lineLineIntersection(_ p1: CGPoint, _ p2: CGPoint, _ p3: CGPoint, _ p4: CGPoint) -> CGFloat {
|
|
let x1 = p1.x
|
|
let y1 = p1.y
|
|
let x2 = p2.x
|
|
let y2 = p2.y
|
|
let x3 = p3.x
|
|
let y3 = p3.y
|
|
let x4 = p4.x
|
|
let y4 = p4.y
|
|
|
|
let d: CGFloat = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
|
|
if abs(d) <= 0.00001 {
|
|
return 10000.0
|
|
}
|
|
|
|
let px: CGFloat = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / d
|
|
let py: CGFloat = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / d
|
|
|
|
let distance: CGFloat = sqrt(pow(px - p1.x, 2.0) + pow(py - p1.y, 2.0))
|
|
return distance
|
|
}
|
|
|
|
let intersectionOuterTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), outerRadius)
|
|
let intersectionInnerTopRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), innerRadius)
|
|
let intersectionOuterBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), outerRadius)
|
|
let intersectionInnerBottomRight = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), innerRadius)
|
|
|
|
let horizontalInset: CGFloat = 2.0
|
|
let intersectionOuterLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), outerRadius) - horizontalInset
|
|
let intersectionInnerLeft = lineCircleIntersection(relLabelCenter, relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y), innerRadius) - horizontalInset
|
|
|
|
let intersectionLine1TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.lowerBound), y: sin(innerAngle.lowerBound)))
|
|
let intersectionLine1BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.lowerBound), y: sin(innerAngle.lowerBound)))
|
|
let intersectionLine2TopRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y + labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.upperBound), y: sin(innerAngle.upperBound)))
|
|
let intersectionLine2BottomRight = lineLineIntersection(relLabelCenter, CGPoint(x: relLabelCenter.x + labelSize.width * 0.5, y: relLabelCenter.y - labelSize.height * 0.5), CGPoint(), CGPoint(x: cos(innerAngle.upperBound), y: sin(innerAngle.upperBound)))
|
|
|
|
var distances: [CGFloat] = [
|
|
intersectionOuterTopRight,
|
|
intersectionInnerTopRight,
|
|
intersectionOuterBottomRight,
|
|
intersectionInnerBottomRight,
|
|
intersectionOuterLeft,
|
|
intersectionInnerLeft
|
|
]
|
|
|
|
if innerAngle.upperBound - innerAngle.lowerBound < CGFloat.pi / 2.0 {
|
|
distances.append(contentsOf: [
|
|
intersectionLine1TopRight,
|
|
intersectionLine1BottomRight,
|
|
intersectionLine2TopRight,
|
|
intersectionLine2BottomRight
|
|
] as [CGFloat])
|
|
}
|
|
|
|
var minDistance: CGFloat = 1000.0
|
|
for distance in distances {
|
|
minDistance = min(minDistance, max(distance, 1.0))
|
|
}
|
|
|
|
let diagonalAngle = atan2(labelSize.height, labelSize.width)
|
|
|
|
let maxHalfWidth = cos(diagonalAngle) * minDistance
|
|
let maxHalfHeight = sin(diagonalAngle) * minDistance
|
|
|
|
let maxSize = CGSize(width: maxHalfWidth * 2.0, height: maxHalfHeight * 2.0)
|
|
let finalSize = CGSize(width: min(labelSize.width, maxSize.width), height: min(labelSize.height, maxSize.height))
|
|
|
|
let currentScale = finalSize.width / labelSize.width
|
|
|
|
if currentScale >= 1.0 - 0.001 {
|
|
resultLabel = CalculatedLabel(
|
|
image: labelImage,
|
|
alpha: 1.0 * normalAlpha,
|
|
angle: midAngle,
|
|
radius: centerDistance,
|
|
scale: 1.0
|
|
)
|
|
break
|
|
}
|
|
if let resultLabel {
|
|
if resultLabel.scale > currentScale {
|
|
continue
|
|
}
|
|
}
|
|
resultLabel = CalculatedLabel(
|
|
image: labelImage,
|
|
alpha: (currentScale >= 0.4 ? 1.0 : 0.0) * normalAlpha,
|
|
angle: midAngle,
|
|
radius: centerDistance,
|
|
scale: currentScale
|
|
)
|
|
}
|
|
} else {
|
|
let midAngle: CGFloat = (innerAngle.lowerBound + innerAngle.upperBound) * 0.5
|
|
let centerDistance: CGFloat = (innerRadius + (outerRadius - innerRadius) * 0.5)
|
|
|
|
resultLabel = CalculatedLabel(
|
|
image: labelImage,
|
|
alpha: 0.0,
|
|
angle: midAngle,
|
|
radius: centerDistance,
|
|
scale: 0.001
|
|
)
|
|
}
|
|
|
|
if let resultLabel {
|
|
self.sections[index].label = resultLabel
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct Particle {
|
|
var trackIndex: Int
|
|
var position: CGPoint
|
|
var scale: CGFloat
|
|
var alpha: CGFloat
|
|
var direction: CGPoint
|
|
var velocity: CGFloat
|
|
|
|
init(
|
|
trackIndex: Int,
|
|
position: CGPoint,
|
|
scale: CGFloat,
|
|
alpha: CGFloat,
|
|
direction: CGPoint,
|
|
velocity: CGFloat
|
|
) {
|
|
self.trackIndex = trackIndex
|
|
self.position = position
|
|
self.scale = scale
|
|
self.alpha = alpha
|
|
self.direction = direction
|
|
self.velocity = velocity
|
|
}
|
|
|
|
mutating func update(deltaTime: CGFloat) {
|
|
var position = self.position
|
|
position.x += self.direction.x * self.velocity * deltaTime
|
|
position.y += self.direction.y * self.velocity * deltaTime
|
|
self.position = position
|
|
}
|
|
}
|
|
|
|
private final class ParticleSet {
|
|
private let innerRadius: CGFloat
|
|
private let maxRadius: CGFloat
|
|
private(set) var particles: [Particle] = []
|
|
|
|
init(innerRadius: CGFloat, maxRadius: CGFloat, preAdvance: Bool) {
|
|
self.innerRadius = innerRadius
|
|
self.maxRadius = maxRadius
|
|
|
|
self.generateParticles(preAdvance: preAdvance)
|
|
}
|
|
|
|
private func generateParticles(preAdvance: Bool) {
|
|
let maxDirections = 24
|
|
|
|
if self.particles.count < maxDirections {
|
|
var allTrackIndices: [Int] = Array(repeating: 0, count: maxDirections)
|
|
for i in 0 ..< maxDirections {
|
|
allTrackIndices[i] = i
|
|
}
|
|
var takenIndexCount = 0
|
|
for particle in self.particles {
|
|
allTrackIndices[particle.trackIndex] = -1
|
|
takenIndexCount += 1
|
|
}
|
|
var availableTrackIndices: [Int] = []
|
|
availableTrackIndices.reserveCapacity(maxDirections - takenIndexCount)
|
|
for index in allTrackIndices {
|
|
if index != -1 {
|
|
availableTrackIndices.append(index)
|
|
}
|
|
}
|
|
|
|
if !availableTrackIndices.isEmpty {
|
|
availableTrackIndices.shuffle()
|
|
|
|
for takeIndex in availableTrackIndices {
|
|
let directionIndex = takeIndex
|
|
let angle = (CGFloat(directionIndex % maxDirections) / CGFloat(maxDirections)) * CGFloat.pi * 2.0
|
|
|
|
let direction = CGPoint(x: cos(angle), y: sin(angle))
|
|
let velocity = CGFloat.random(in: 20.0 ..< 40.0)
|
|
let alpha = CGFloat.random(in: 0.1 ..< 0.4)
|
|
let scale = CGFloat.random(in: 0.5 ... 1.0) * 0.22
|
|
|
|
var position = CGPoint(x: 100.0, y: 100.0)
|
|
var initialOffset: CGFloat = 0.4
|
|
if preAdvance {
|
|
initialOffset = CGFloat.random(in: initialOffset ... 1.0)
|
|
}
|
|
position.x += direction.x * initialOffset * 105.0
|
|
position.y += direction.y * initialOffset * 105.0
|
|
|
|
let particle = Particle(
|
|
trackIndex: directionIndex,
|
|
position: position,
|
|
scale: scale,
|
|
alpha: alpha,
|
|
direction: direction,
|
|
velocity: velocity
|
|
)
|
|
self.particles.append(particle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func update(deltaTime: CGFloat) {
|
|
let size = CGSize(width: 200.0, height: 200.0)
|
|
let radius = size.width * 0.5 + 10.0
|
|
for i in (0 ..< self.particles.count).reversed() {
|
|
self.particles[i].update(deltaTime: deltaTime)
|
|
let position = self.particles[i].position
|
|
|
|
let distance = sqrt(pow(position.x - size.width * 0.5, 2.0) + pow(position.y - size.height * 0.5, 2.0))
|
|
if distance > radius {
|
|
self.particles.remove(at: i)
|
|
}
|
|
}
|
|
|
|
self.generateParticles(preAdvance: false)
|
|
}
|
|
}
|
|
|
|
private final class SectionLayer: SimpleLayer {
|
|
private let maskLayer: SimpleShapeLayer
|
|
private let gradientLayer: SimpleGradientLayer
|
|
private let labelLayer: SimpleLayer
|
|
|
|
private var currentLabelImage: UIImage?
|
|
|
|
private var particleImage: UIImage?
|
|
private var particleLayers: [SimpleLayer] = []
|
|
|
|
init(particle: String?) {
|
|
self.maskLayer = SimpleShapeLayer()
|
|
self.maskLayer.fillColor = UIColor.white.cgColor
|
|
|
|
self.gradientLayer = SimpleGradientLayer()
|
|
self.gradientLayer.type = .radial
|
|
self.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
|
|
self.gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
|
|
|
|
self.labelLayer = SimpleLayer()
|
|
|
|
super.init()
|
|
|
|
self.mask = self.maskLayer
|
|
self.addSublayer(self.gradientLayer)
|
|
self.addSublayer(self.labelLayer)
|
|
|
|
if let particle {
|
|
self.particleImage = UIImage(bundleImageName: particle)?.precomposed()
|
|
}
|
|
}
|
|
|
|
override init(layer: Any) {
|
|
self.maskLayer = SimpleShapeLayer()
|
|
self.gradientLayer = SimpleGradientLayer()
|
|
self.labelLayer = SimpleLayer()
|
|
|
|
super.init(layer: layer)
|
|
}
|
|
|
|
required init(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
func isPointOnGraph(point: CGPoint) -> Bool {
|
|
if let path = self.maskLayer.path {
|
|
return path.contains(point)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tooltipLocation() -> CGPoint {
|
|
return self.labelLayer.position
|
|
}
|
|
|
|
func update(size: CGSize, section: CalculatedSection) {
|
|
self.maskLayer.frame = CGRect(origin: CGPoint(), size: size)
|
|
self.gradientLayer.frame = CGRect(origin: CGPoint(), size: size)
|
|
|
|
let normalColor = section.color.cgColor
|
|
let darkerColor = section.color.withMultipliedBrightnessBy(0.96).cgColor
|
|
let colors: [CGColor] = [
|
|
darkerColor,
|
|
normalColor,
|
|
normalColor,
|
|
normalColor,
|
|
darkerColor
|
|
]
|
|
self.gradientLayer.colors = colors
|
|
|
|
let locations: [CGFloat] = [
|
|
0.0,
|
|
0.3,
|
|
0.5,
|
|
0.7,
|
|
1.0
|
|
]
|
|
self.gradientLayer.locations = locations.map { location in
|
|
let location = location * 0.5 + 0.5
|
|
return location as NSNumber
|
|
}
|
|
|
|
let path = CGMutablePath()
|
|
path.addArc(center: CGPoint(x: size.width * 0.5, y: size.height * 0.5), radius: section.innerRadius, startAngle: section.innerAngle.upperBound, endAngle: section.innerAngle.lowerBound, clockwise: true)
|
|
path.addArc(center: CGPoint(x: size.width * 0.5, y: size.height * 0.5), radius: section.outerRadius, startAngle: section.outerAngle.lowerBound, endAngle: section.outerAngle.upperBound, clockwise: false)
|
|
self.maskLayer.path = path
|
|
|
|
if let label = section.label {
|
|
if self.currentLabelImage !== label.image {
|
|
self.currentLabelImage = label.image
|
|
self.labelLayer.contents = label.image.cgImage
|
|
}
|
|
|
|
let position = CGPoint(x: size.width * 0.5 + cos(label.angle) * label.radius, y: size.height * 0.5 + sin(label.angle) * label.radius)
|
|
let labelSize = CGSize(width: label.image.size.width * label.scale, height: label.image.size.height * label.scale)
|
|
let labelFrame = CGRect(origin: CGPoint(x: position.x - labelSize.width * 0.5, y: position.y - labelSize.height * 0.5), size: labelSize)
|
|
self.labelLayer.frame = labelFrame
|
|
self.labelLayer.opacity = Float(label.alpha)
|
|
} else {
|
|
self.currentLabelImage = nil
|
|
self.labelLayer.contents = nil
|
|
}
|
|
}
|
|
|
|
func updateParticles(particleSet: ParticleSet, alpha: CGFloat) {
|
|
guard let particleImage = self.particleImage else {
|
|
return
|
|
}
|
|
for i in 0 ..< particleSet.particles.count {
|
|
let particle = particleSet.particles[i]
|
|
|
|
let particleLayer: SimpleLayer
|
|
if i < self.particleLayers.count {
|
|
particleLayer = self.particleLayers[i]
|
|
particleLayer.isHidden = false
|
|
} else {
|
|
particleLayer = SimpleLayer()
|
|
particleLayer.contents = particleImage.cgImage
|
|
particleLayer.bounds = CGRect(origin: CGPoint(), size: particleImage.size)
|
|
self.particleLayers.append(particleLayer)
|
|
self.insertSublayer(particleLayer, above: self.gradientLayer)
|
|
}
|
|
|
|
particleLayer.position = particle.position
|
|
particleLayer.transform = CATransform3DMakeScale(particle.scale, particle.scale, 1.0)
|
|
particleLayer.opacity = Float(particle.alpha * alpha)
|
|
}
|
|
if particleSet.particles.count < self.particleLayers.count {
|
|
for i in particleSet.particles.count ..< self.particleLayers.count {
|
|
self.particleLayers[i].isHidden = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class DoneLayer: SimpleLayer {
|
|
private let particleColor: UIColor
|
|
private let maskShapeLayer: CAShapeLayer
|
|
private var particleImage: UIImage?
|
|
private var particleSet: ParticleSet?
|
|
private var particleLayers: [SimpleLayer] = []
|
|
|
|
init(particleColor: UIColor) {
|
|
self.particleColor = particleColor
|
|
|
|
self.maskShapeLayer = CAShapeLayer()
|
|
self.maskShapeLayer.fillColor = UIColor.black.cgColor
|
|
self.maskShapeLayer.fillRule = .evenOdd
|
|
|
|
super.init()
|
|
|
|
self.particleImage = UIImage(bundleImageName: "Settings/Storage/ParticleStar")?.precomposed()
|
|
|
|
let path = CGMutablePath()
|
|
|
|
path.addRect(CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 200.0, height: 200.0)))
|
|
path.addEllipse(in: CGRect(origin: CGPoint(x: floor((200.0 - 102.0) * 0.5), y: floor((200.0 - 102.0) * 0.5)), size: CGSize(width: 102.0, height: 102.0)))
|
|
|
|
self.maskShapeLayer.path = path
|
|
self.mask = self.maskShapeLayer
|
|
|
|
self.particleSet = ParticleSet(innerRadius: 45.0, maxRadius: 100.0, preAdvance: true)
|
|
}
|
|
|
|
override init(layer: Any) {
|
|
self.particleColor = .white
|
|
self.maskShapeLayer = CAShapeLayer()
|
|
|
|
super.init(layer: layer)
|
|
}
|
|
|
|
required init(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
func updateParticles(deltaTime: CGFloat) {
|
|
guard let particleSet = self.particleSet else {
|
|
return
|
|
}
|
|
particleSet.update(deltaTime: deltaTime)
|
|
|
|
let size = CGSize(width: 200.0, height: 200.0)
|
|
|
|
guard let particleImage = self.particleImage else {
|
|
return
|
|
}
|
|
for i in 0 ..< particleSet.particles.count {
|
|
let particle = particleSet.particles[i]
|
|
|
|
let particleLayer: SimpleLayer
|
|
if i < self.particleLayers.count {
|
|
particleLayer = self.particleLayers[i]
|
|
particleLayer.isHidden = false
|
|
} else {
|
|
particleLayer = SimpleLayer()
|
|
particleLayer.contents = particleImage.cgImage
|
|
particleLayer.bounds = CGRect(origin: CGPoint(), size: particleImage.size)
|
|
self.particleLayers.append(particleLayer)
|
|
self.addSublayer(particleLayer)
|
|
|
|
particleLayer.layerTintColor = self.particleColor.cgColor
|
|
}
|
|
|
|
particleLayer.position = particle.position
|
|
particleLayer.transform = CATransform3DMakeScale(particle.scale * 1.2, particle.scale * 1.2, 1.0)
|
|
|
|
let distance = sqrt(pow(particle.position.x - size.width * 0.5, 2.0) + pow(particle.position.y - size.height * 0.5, 2.0))
|
|
var mulAlpha: CGFloat = 1.0
|
|
let outerDistanceNorm: CGFloat = 20.0
|
|
if distance > 100.0 - outerDistanceNorm {
|
|
let outerDistanceFactor: CGFloat = (100.0 - distance) / outerDistanceNorm
|
|
let alphaFactor: CGFloat = max(0.0, min(1.0, outerDistanceFactor))
|
|
mulAlpha = alphaFactor
|
|
}
|
|
|
|
particleLayer.opacity = Float(particle.alpha * mulAlpha)
|
|
}
|
|
if particleSet.particles.count < self.particleLayers.count {
|
|
for i in particleSet.particles.count ..< self.particleLayers.count {
|
|
self.particleLayers[i].isHidden = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class ChartDataView: UIView {
|
|
private(set) var theme: PresentationTheme?
|
|
private(set) var data: ChartData?
|
|
private var emptyColor: UIColor?
|
|
private(set) var selectedKey: AnyHashable?
|
|
|
|
private var currentAnimation: (start: CalculatedLayout, startTime: Double, duration: Double)?
|
|
private var currentLayout: CalculatedLayout?
|
|
private var animator: DisplayLinkAnimator?
|
|
|
|
private var displayLink: SharedDisplayLinkDriver.Link?
|
|
|
|
private var sectionLayers: [AnyHashable: SectionLayer] = [:]
|
|
private let particleSet: ParticleSet
|
|
private var doneLayer: DoneLayer?
|
|
|
|
override init(frame: CGRect) {
|
|
self.particleSet = ParticleSet(innerRadius: 50.0, maxRadius: 100.0, preAdvance: true)
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.backgroundColor = nil
|
|
self.isOpaque = false
|
|
|
|
var previousTimestamp: Double?
|
|
self.displayLink = SharedDisplayLinkDriver.shared.add(needsHighestFramerate: true, { [weak self] in
|
|
let timestamp = CACurrentMediaTime()
|
|
var delta: Double
|
|
if let previousTimestamp {
|
|
delta = timestamp - previousTimestamp
|
|
} else {
|
|
delta = 1.0 / 60.0
|
|
}
|
|
previousTimestamp = timestamp
|
|
|
|
if delta < 0.0 {
|
|
delta = 1.0 / 60.0
|
|
} else if delta > 0.5 {
|
|
delta = 1.0 / 60.0
|
|
}
|
|
|
|
self?.update(deltaTime: CGFloat(delta))
|
|
})
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
self.animator?.invalidate()
|
|
}
|
|
|
|
func sectionKey(at point: CGPoint) -> AnyHashable? {
|
|
for (id, itemLayer) in self.sectionLayers {
|
|
if itemLayer.isPointOnGraph(point: point) {
|
|
return id
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func tooltipLocation(forKey key: AnyHashable) -> CGPoint? {
|
|
for (id, itemLayer) in self.sectionLayers {
|
|
if id == key {
|
|
return itemLayer.tooltipLocation()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setItems(theme: PresentationTheme, emptyColor: UIColor, data: ChartData, selectedKey: AnyHashable?, animated: Bool) {
|
|
self.emptyColor = emptyColor
|
|
|
|
let data = processChartData(data: data)
|
|
|
|
if self.theme !== theme || self.data != data || self.selectedKey != selectedKey {
|
|
self.theme = theme
|
|
self.selectedKey = selectedKey
|
|
let previousData = self.data
|
|
|
|
if animated, let previous = self.currentLayout {
|
|
var initialState = previous
|
|
if let currentAnimation = self.currentAnimation {
|
|
let currentProgress: Double = max(0.0, min(1.0, (CACurrentMediaTime() - currentAnimation.startTime) / currentAnimation.duration))
|
|
let mappedProgress = listViewAnimationCurveSystem(CGFloat(currentProgress))
|
|
initialState = CalculatedLayout(interpolating: currentAnimation.start, to: previous, progress: mappedProgress, size: previous.size)
|
|
}
|
|
|
|
let targetLayout: CalculatedLayout
|
|
if let previousData = previousData, data.items.isEmpty {
|
|
targetLayout = CalculatedLayout(
|
|
size: CGSize(width: 200.0, height: 200.0),
|
|
items: previousData.items,
|
|
selectedKey: self.selectedKey,
|
|
isEmpty: true,
|
|
emptyColor: emptyColor
|
|
)
|
|
} else {
|
|
targetLayout = CalculatedLayout(
|
|
size: CGSize(width: 200.0, height: 200.0),
|
|
items: data.items,
|
|
selectedKey: self.selectedKey,
|
|
isEmpty: false,
|
|
emptyColor: emptyColor
|
|
)
|
|
}
|
|
|
|
self.currentLayout = targetLayout
|
|
self.currentAnimation = (initialState, CACurrentMediaTime(), 0.4)
|
|
} else {
|
|
if data.items.isEmpty {
|
|
self.currentLayout = CalculatedLayout(
|
|
size: CGSize(width: 200.0, height: 200.0),
|
|
items: [.init(id: AnyHashable(StorageUsageScreenComponent.Category.other), displayValue: 0.0, displaySize: 0, value: 1.0, color: .green, particle: "Settings/Storage/ParticleOther", title: "", mergeable: false, mergeFactor: 1.0)],
|
|
selectedKey: self.selectedKey,
|
|
isEmpty: true,
|
|
emptyColor: emptyColor
|
|
)
|
|
} else {
|
|
self.currentLayout = CalculatedLayout(
|
|
size: CGSize(width: 200.0, height: 200.0),
|
|
items: data.items,
|
|
selectedKey: self.selectedKey,
|
|
isEmpty: data.items.isEmpty,
|
|
emptyColor: emptyColor
|
|
)
|
|
}
|
|
}
|
|
|
|
self.data = data
|
|
|
|
self.update(deltaTime: 0.0)
|
|
}
|
|
}
|
|
|
|
private func update(deltaTime: CGFloat) {
|
|
self.particleSet.update(deltaTime: deltaTime)
|
|
|
|
var validIds: [AnyHashable] = []
|
|
if let currentLayout = self.currentLayout, let emptyColor = self.emptyColor {
|
|
var effectiveLayout = currentLayout
|
|
var verticalOffset: CGFloat = 0.0
|
|
var particleAlpha: CGFloat = 1.0
|
|
var rotationAngle: CGFloat = 0.0
|
|
let emptyRotationAngle: CGFloat = CGFloat.pi
|
|
let emptyVerticalOffset: CGFloat = (92.0 - 200.0) * 0.5
|
|
if let currentAnimation = self.currentAnimation {
|
|
let currentProgress: Double = max(0.0, min(1.0, (CACurrentMediaTime() - currentAnimation.startTime) / currentAnimation.duration))
|
|
let mappedProgress = listViewAnimationCurveSystem(CGFloat(currentProgress))
|
|
|
|
effectiveLayout = CalculatedLayout(interpolating: currentAnimation.start, to: currentLayout, progress: mappedProgress, size: currentLayout.size)
|
|
|
|
let fromVerticalOffset: CGFloat
|
|
let fromRotationAngle: CGFloat
|
|
if currentAnimation.start.isEmpty {
|
|
fromVerticalOffset = emptyVerticalOffset
|
|
fromRotationAngle = emptyRotationAngle
|
|
} else {
|
|
fromVerticalOffset = 0.0
|
|
fromRotationAngle = 0.0
|
|
}
|
|
let toVerticalOffset: CGFloat
|
|
let toRotationAngle: CGFloat
|
|
if currentLayout.isEmpty {
|
|
toVerticalOffset = emptyVerticalOffset
|
|
toRotationAngle = emptyRotationAngle
|
|
} else {
|
|
toVerticalOffset = 0.0
|
|
toRotationAngle = 0.0
|
|
}
|
|
|
|
verticalOffset = (1.0 - mappedProgress) * fromVerticalOffset + mappedProgress * toVerticalOffset
|
|
rotationAngle = (1.0 - mappedProgress) * fromRotationAngle + mappedProgress * toRotationAngle
|
|
|
|
if currentLayout.isEmpty {
|
|
particleAlpha = 1.0 - mappedProgress
|
|
}
|
|
|
|
if currentProgress >= 1.0 - CGFloat.ulpOfOne {
|
|
self.currentAnimation = nil
|
|
}
|
|
} else {
|
|
if currentLayout.isEmpty {
|
|
verticalOffset = emptyVerticalOffset
|
|
particleAlpha = 0.0
|
|
rotationAngle = emptyRotationAngle
|
|
}
|
|
}
|
|
|
|
if currentLayout.isEmpty {
|
|
let doneLayer: DoneLayer
|
|
if let current = self.doneLayer {
|
|
doneLayer = current
|
|
} else {
|
|
doneLayer = DoneLayer(particleColor: emptyColor)
|
|
self.doneLayer = doneLayer
|
|
self.layer.insertSublayer(doneLayer, at: 0)
|
|
}
|
|
doneLayer.updateParticles(deltaTime: deltaTime)
|
|
doneLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: 200.0, height: 200.0))
|
|
doneLayer.opacity = Float(1.0 - particleAlpha)
|
|
} else {
|
|
if let doneLayer = self.doneLayer {
|
|
self.doneLayer = nil
|
|
doneLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
for section in effectiveLayout.sections {
|
|
validIds.append(section.id)
|
|
|
|
let sectionLayer: SectionLayer
|
|
if let current = self.sectionLayers[section.id] {
|
|
sectionLayer = current
|
|
} else {
|
|
sectionLayer = SectionLayer(particle: section.particle)
|
|
self.sectionLayers[section.id] = sectionLayer
|
|
self.layer.addSublayer(sectionLayer)
|
|
}
|
|
|
|
let sectionLayerFrame = CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: 200.0, height: 200.0))
|
|
sectionLayer.position = sectionLayerFrame.center
|
|
sectionLayer.bounds = CGRect(origin: CGPoint(), size: sectionLayerFrame.size)
|
|
sectionLayer.transform = CATransform3DMakeRotation(rotationAngle, 0.0, 0.0, 1.0)
|
|
sectionLayer.update(size: sectionLayer.bounds.size, section: section)
|
|
sectionLayer.updateParticles(particleSet: self.particleSet, alpha: particleAlpha)
|
|
}
|
|
}
|
|
|
|
var removeIds: [AnyHashable] = []
|
|
for (id, sectionLayer) in self.sectionLayers {
|
|
if !validIds.contains(id) {
|
|
removeIds.append(id)
|
|
sectionLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
for id in removeIds {
|
|
self.sectionLayers.removeValue(forKey: id)
|
|
}
|
|
}
|
|
}
|
|
|
|
class View: UIView {
|
|
private let dataView: ChartDataView
|
|
private var tooltip: (key: AnyHashable, value: ComponentView<Empty>)?
|
|
|
|
var selectedKey: AnyHashable?
|
|
|
|
private var component: PieChartComponent?
|
|
private weak var state: EmptyComponentState?
|
|
|
|
override init(frame: CGRect) {
|
|
self.dataView = ChartDataView()
|
|
|
|
super.init(frame: frame)
|
|
|
|
self.addSubview(self.dataView)
|
|
|
|
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
|
|
if case .ended = recognizer.state {
|
|
let point = recognizer.location(in: self.dataView)
|
|
if let key = self.dataView.sectionKey(at: point), key != AnyHashable("empty") {
|
|
if self.selectedKey == key {
|
|
self.selectedKey = nil
|
|
} else {
|
|
self.selectedKey = key
|
|
}
|
|
} else {
|
|
self.selectedKey = nil
|
|
}
|
|
self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring)))
|
|
}
|
|
}
|
|
|
|
func update(component: PieChartComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
|
let dataUpdated = self.component?.chartData != component.chartData
|
|
|
|
self.state = state
|
|
self.component = component
|
|
|
|
if dataUpdated {
|
|
self.selectedKey = nil
|
|
}
|
|
|
|
transition.setFrame(view: self.dataView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - 200.0) / 2.0), y: 0.0), size: CGSize(width: 200.0, height: 200.0)))
|
|
self.dataView.setItems(theme: component.theme, emptyColor: component.emptyColor, data: component.chartData, selectedKey: self.selectedKey, animated: !transition.animation.isImmediate)
|
|
|
|
if let selectedKey = self.selectedKey, let item = component.chartData.items.first(where: { $0.id == selectedKey }) {
|
|
let tooltip: ComponentView<Empty>
|
|
var tooltipTransition = transition
|
|
var animateIn = false
|
|
if let current = self.tooltip, current.key == AnyHashable(selectedKey) {
|
|
tooltip = current.value
|
|
} else if let current = self.tooltip {
|
|
if let tooltipView = current.value.view {
|
|
transition.setAlpha(view: tooltipView, alpha: 0.0, completion: { [weak tooltipView] _ in
|
|
tooltipView?.removeFromSuperview()
|
|
})
|
|
}
|
|
tooltipTransition = .immediate
|
|
animateIn = true
|
|
tooltip = ComponentView()
|
|
self.tooltip = (selectedKey, tooltip)
|
|
} else {
|
|
tooltipTransition = .immediate
|
|
animateIn = true
|
|
tooltip = ComponentView()
|
|
self.tooltip = (selectedKey, tooltip)
|
|
}
|
|
|
|
let fractionValue: Double = floor(item.displayValue * 100.0 * 10.0) / 10.0
|
|
let fractionString: String
|
|
if fractionValue < 0.1 {
|
|
fractionString = "<0.1%"
|
|
} else if abs(Double(Int(fractionValue)) - fractionValue) < 0.001 {
|
|
fractionString = "\(Int(fractionValue))%"
|
|
} else {
|
|
fractionString = "\(fractionValue)%"
|
|
}
|
|
|
|
let tooltipSize = tooltip.update(
|
|
transition: tooltipTransition,
|
|
component: AnyComponent(ChartSelectionTooltip(
|
|
theme: component.theme,
|
|
fractionText: fractionString,
|
|
title: item.title,
|
|
sizeText: dataSizeString(Int(item.displaySize), formatting: DataSizeStringFormatting(strings: component.strings, decimalSeparator: "."))
|
|
)),
|
|
environment: {},
|
|
containerSize: availableSize
|
|
)
|
|
|
|
if let relativeTooltipLocation = self.dataView.tooltipLocation(forKey: selectedKey) {
|
|
let tooltipLocation = relativeTooltipLocation.offsetBy(dx: self.dataView.frame.minX, dy: self.dataView.frame.minY)
|
|
let tooltipFrame = CGRect(origin: CGPoint(x: floor(tooltipLocation.x - tooltipSize.width / 2.0), y: tooltipLocation.y - 16.0 - tooltipSize.height), size: tooltipSize)
|
|
|
|
if let tooltipView = tooltip.view {
|
|
if tooltipView.superview == nil {
|
|
self.addSubview(tooltipView)
|
|
}
|
|
tooltipTransition.setFrame(view: tooltipView, frame: tooltipFrame)
|
|
if animateIn {
|
|
transition.animateAlpha(view: tooltipView, from: 0.0, to: 1.0)
|
|
transition.animateScale(view: tooltipView, from: 0.8, to: 1.0)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if let tooltip = self.tooltip {
|
|
self.tooltip = nil
|
|
if let tooltipView = tooltip.value.view {
|
|
transition.setAlpha(view: tooltipView, alpha: 0.0, completion: { [weak tooltipView] _ in
|
|
tooltipView?.removeFromSuperview()
|
|
})
|
|
transition.setScale(view: tooltipView, scale: 0.8)
|
|
}
|
|
}
|
|
}
|
|
|
|
return CGSize(width: availableSize.width, height: 200.0)
|
|
}
|
|
}
|
|
|
|
func makeView() -> View {
|
|
return View(frame: CGRect())
|
|
}
|
|
|
|
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: Transition) -> CGSize {
|
|
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
|
}
|
|
}
|