Created
July 26, 2016 19:11
-
-
Save JasonCanCode/4ebc75523bb4fade25c616d9b4770014 to your computer and use it in GitHub Desktop.
An interactive line graph
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
typealias ConnectionDrawing = Void -> Void | |
class LineGraphView: UIView { | |
var data = [Double]() | |
var offset: CGFloat = 0.0 | |
var shownRange = 0 ..< 0 | |
var maximumValueShown: Double = 0.0 | |
var gestureRecognizer = UISwipeGestureRecognizer() | |
var pointBorderColor = UIColor.whiteColor() | |
var pointFillerColor = UIColor.lightGrayColor() | |
var lineColor = UIColor.lightGrayColor() | |
func configure(data data: [Double], shownRange: Range<Int>, maximumValueShown: Double, offset: CGFloat? = nil) { | |
self.data = data | |
self.maximumValueShown = maximumValueShown | |
self.shownRange = shownRange // Must set range before using gap | |
self.offset = offset ?? gap / 2 | |
gestureRecognizer.delegate = self | |
addGestureRecognizer(gestureRecognizer) | |
updateConnections() | |
} | |
override func drawRect(rect: CGRect) { | |
for draw in lineDrawings { | |
draw() | |
} | |
for draw in circleDrawings { | |
draw() | |
} | |
} | |
} | |
extension LineGraphView: PointConvertable { | |
var gap: CGFloat { | |
return horizontalGap(amountOfPointsRendered: shownRange.count, viewWidth: bounds.size.width) | |
} | |
func pointsToShow() -> [CGPoint?] { | |
return pointsFromData(data, withinRange: shownRange, maximumValueShown: maximumValueShown, inFrame: bounds, horizontalOffset: offset, includeLeadingAndTrailingPoints: true) | |
} | |
} | |
// MARK: Drawing | |
private var lineDrawings = [ConnectionDrawing]() | |
private var circleDrawings = [ConnectionDrawing]() | |
extension LineGraphView { | |
func updateConnections() { | |
removeConnections() | |
let centers = pointsToShow() | |
for (index, centerOne) in centers.enumerate() where index < centers.count - 1 { | |
if let centerOne = centerOne, centerTwo = centers[index + 1] { | |
addLineBetween(pointA: centerOne, pointB: centerTwo) | |
} | |
} | |
for center in centers where center != nil { | |
addCircle(center: center!) | |
} | |
} | |
private func addLineBetween(pointA pointA: CGPoint, pointB: CGPoint) { | |
lineDrawings.append({ | |
let path = UIBezierPath() | |
path.lineWidth = 2.0 | |
path.moveToPoint(pointA) | |
path.addLineToPoint(pointB) | |
self.lineColor.setStroke() | |
path.stroke() | |
}) | |
setNeedsDisplay() | |
} | |
private func addCircle(center center: CGPoint) { | |
circleDrawings.append({ | |
self.drawCircle(center: center, sizeSquare: 10, color: self.pointBorderColor) | |
self.drawCircle(center: center, sizeSquare: 8, color: self.pointFillerColor) | |
}) | |
setNeedsDisplay() | |
} | |
private func rectFromCenter(center: CGPoint, size: CGSize) -> CGRect { | |
return CGRectMake(center.x - (size.width / 2), center.y - (size.height / 2), size.width, size.height) | |
} | |
private func drawCircle(center center: CGPoint, sizeSquare: CGFloat, color: UIColor) { | |
let rect = rectFromCenter(center, size: CGSize(width: sizeSquare, height: sizeSquare)) | |
let path = UIBezierPath(ovalInRect: rect) | |
color.setFill() | |
path.fill() | |
} | |
private func removeConnections() { | |
lineDrawings = [] | |
circleDrawings = [] | |
setNeedsDisplay() | |
} | |
} | |
// MARK: Swipe to Shift Foward/Back | |
private enum Shift { | |
case Left, Right | |
} | |
private var originalStartIndex = 0 | |
private var touchPoint: CGPoint? | |
extension LineGraphView: UIGestureRecognizerDelegate { | |
override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) { | |
originalStartIndex = shownRange.startIndex | |
touchPoint = touches.first?.locationInView(self) | |
} | |
override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) { | |
guard let oldPoint = touchPoint, newPoint = touches.first?.locationInView(self) | |
else { return } | |
let difference = newPoint.x - oldPoint.x | |
if difference > 20 { | |
shiftRange(.Left) | |
} else if difference < -20 { | |
shiftRange(.Right) | |
} | |
} | |
private func shiftRange(shift: Shift) { | |
switch shift { | |
case .Left where shownRange.startIndex > 0: | |
let startIndex = shownRange.startIndex - shownRange.count | |
let endIndex = shownRange.endIndex - shownRange.count | |
shownRange = shownRange.startIndex == originalStartIndex ? startIndex ..< endIndex : shownRange | |
case .Right where shownRange.endIndex < data.count: | |
let startIndex = shownRange.startIndex + shownRange.count | |
let endIndex = shownRange.endIndex + shownRange.count | |
shownRange = shownRange.startIndex == originalStartIndex ? startIndex ..< endIndex : shownRange | |
default: | |
break | |
} | |
updateConnections() | |
} | |
} | |
protocol PointConvertable { | |
func horizontalGap(amountOfPointsRendered rendered: Int, viewWidth: CGFloat) -> CGFloat | |
func pointFromData(data: Double, atIndex index: Int, inFrame frame: CGRect, horizontalOffset offset: CGFloat, amountOfPointsRendered amount: Int, maximumValueShown maxValue: Double) -> CGPoint | |
func pointsFromData(data: [Double], withinRange range: Range<Int>, maximumValueShown maxValue: Double, inFrame frame: CGRect, horizontalOffset offset: CGFloat, includeLeadingAndTrailingPoints hasLeadingTrailing: Bool) -> [CGPoint?] | |
} | |
extension PointConvertable { | |
private func distanceFromTop(figureValue: Double, maxValue: Double, viewHeight: CGFloat) -> CGFloat { | |
return viewHeight - (CGFloat(figureValue / maxValue) * viewHeight) | |
} | |
private func distanceFromLeft(figureValue: Double, indexOfFigureShown figureIndex: Int, horizontalGap gap: CGFloat, horizontalOffset offset: CGFloat) -> CGFloat { | |
return offset + (gap * CGFloat(figureIndex)) | |
} | |
func horizontalGap(amountOfPointsRendered rendered: Int, viewWidth: CGFloat) -> CGFloat { | |
return viewWidth / CGFloat(rendered) | |
} | |
func pointFromData(data: Double, atIndex index: Int, inFrame frame: CGRect, horizontalOffset offset: CGFloat = 0, amountOfPointsRendered amount: Int, maximumValueShown maxValue: Double) -> CGPoint { | |
let figure = data | |
let viewHeight = frame.size.height | |
let viewWidth = frame.size.width | |
let gap = horizontalGap(amountOfPointsRendered: amount, viewWidth: viewWidth) | |
let xPoint = distanceFromLeft(figure, indexOfFigureShown: index, horizontalGap: gap, horizontalOffset: offset) | |
let yPoint = distanceFromTop(figure, maxValue: maxValue, viewHeight: viewHeight) | |
return CGPoint(x: xPoint, y: yPoint) | |
} | |
func pointsFromData(data: [Double], withinRange range: Range<Int>, maximumValueShown maxValue: Double, inFrame frame: CGRect, horizontalOffset offset: CGFloat, includeLeadingAndTrailingPoints hasLeadingTrailing: Bool = false) -> [CGPoint?] { | |
let startIndex = hasLeadingTrailing ? range.startIndex - 1 : range.startIndex | |
let endIndex = hasLeadingTrailing ? range.endIndex + 1 : range.endIndex | |
var points = [CGPoint?]() | |
var shownIndex = hasLeadingTrailing ? -1 : 0 | |
for i in startIndex ..< endIndex { | |
guard i >= 0 && i < data.count else { | |
points.append(nil) | |
shownIndex += 1 | |
continue | |
} | |
let figure = data[i] | |
points.append(pointFromData(figure, atIndex: shownIndex, inFrame: frame, horizontalOffset: offset, amountOfPointsRendered: range.count, maximumValueShown: maxValue)) | |
shownIndex += 1 | |
} | |
return points | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment