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() 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, 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, 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 var outerAngle: Range var innerRadius: CGFloat var outerRadius: CGFloat var label: CalculatedLabel? init( id: AnyHashable, color: UIColor, particle: String?, title: String, innerAngle: Range, outerAngle: Range, 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 = left.innerAngle.lowerBound.interpolate(to: right.innerAngle.lowerBound, amount: progress) ..< left.innerAngle.upperBound.interpolate(to: right.innerAngle.upperBound, amount: progress) let outerAngle: Range = 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, outerAngle: Range, 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 self.displayLink = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max, { [weak self] delta in 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)? 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, 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 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, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }