Tusker/Tusker/Views/TrendHistoryView.swift

144 lines
4.9 KiB
Swift

//
// TrendHistoryView.swift
// Tusker
//
// Created by Shadowfacts on 1/24/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class TrendHistoryView: UIView {
private var history: [History]?
private let curveRadius: CGFloat = 10
/// The base background color used for the graph fill.
var effectiveBackgroundColor = UIColor.systemBackground
override func layoutSubviews() {
super.layoutSubviews()
createLayers()
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
createLayers()
}
func setHistory(_ history: [History]?) {
if let history = history {
self.history = history.sorted(by: { $0.day < $1.day })
} else {
self.history = nil
}
createLayers()
}
private func createLayers() {
guard let history = history,
history.count >= 2,
!bounds.isEmpty else {
return
}
let maxUses = history.max(by: { $0.uses < $1.uses })!.uses
// remove old layers if this view is being re-used
layer.sublayers?.forEach { $0.removeFromSuperlayer() }
let path = UIBezierPath()
let widthStep = bounds.width / CGFloat(history.count - 1)
let points: [CGPoint] = history.enumerated().map { (index, entry) in
let x = CGFloat(index) * widthStep
let yFrac = CGFloat(entry.uses) / CGFloat(maxUses)
let y = (1 - yFrac) * bounds.height
return CGPoint(x: x, y: y)
}
var gapStartPoints = [CGPoint]()
var gapEndPoints = [CGPoint]()
for (index, point) in points.enumerated().dropFirst().dropLast() {
let prev = points[index - 1]
let next = points[index + 1]
let a = atan((point.y - prev.y) / widthStep)
let b = atan((next.y - point.y) / widthStep)
let innerAngle = .pi - a - b
let gapDistance = curveRadius / sin(innerAngle / 2)
let x1 = point.x - cos(a) * gapDistance
let y1 = point.y - sin(a) * gapDistance
gapStartPoints.append(CGPoint(x: x1, y: y1))
let x2 = point.x + cos(b) * gapDistance
let y2 = point.y + sin(b) * gapDistance
gapEndPoints.append(CGPoint(x: x2, y: y2))
}
path.move(to: points.first!)
for (index, point) in points.dropFirst().dropLast().enumerated() {
path.addLine(to: gapStartPoints[index])
path.addQuadCurve(to: gapEndPoints[index], controlPoint: point)
}
path.addLine(to: points.last!)
let borderLayer = CAShapeLayer()
// copy the border path so we can continue mutating the UIBezierPath to create the fill path
borderLayer.path = path.cgPath.copy()!
borderLayer.strokeColor = tintColor.cgColor
borderLayer.fillColor = nil
borderLayer.lineWidth = lineWidth
borderLayer.lineCap = .round
path.addLine(to: CGPoint(x: bounds.width, y: bounds.height))
path.addLine(to: CGPoint(x: 0, y: bounds.height))
path.addLine(to: points.first!)
let fillLayer = CAShapeLayer()
fillLayer.path = path.cgPath
let fillColor = self.fillColor()
fillLayer.strokeColor = fillColor
fillLayer.fillColor = fillColor
fillLayer.lineWidth = lineWidth
layer.addSublayer(fillLayer)
layer.addSublayer(borderLayer)
}
private var lineWidth: CGFloat {
(traitCollection.preferredContentSizeCategory > .large || UIAccessibility.isBoldTextEnabled) ? 4 : 2
}
// The non-transparent fill color.
// We blend with the view's background color ourselves so that final color is non-transparent,
// otherwise when the fill layer's border and fill overlap, there's a visibly darker patch
// because transparent colors are being blended together.
private func fillColor() -> CGColor {
var backgroundRed: CGFloat = 0
var backgroundGreen: CGFloat = 0
var backgroundBlue: CGFloat = 0
var tintRed: CGFloat = 0
var tintGreen: CGFloat = 0
var tintBlue: CGFloat = 0
traitCollection.performAsCurrent {
effectiveBackgroundColor.getRed(&backgroundRed, green: &backgroundGreen, blue: &backgroundBlue, alpha: nil)
tintColor.getRed(&tintRed, green: &tintGreen, blue: &tintBlue, alpha: nil)
}
let blendedRed = (backgroundRed + tintRed) / 2
let blendedGreen = (backgroundGreen + tintGreen) / 2
let blendedBlue = (backgroundBlue + tintBlue) / 2
return CGColor(red: blendedRed, green: blendedGreen, blue: blendedBlue, alpha: 1)
}
}