swiftswiftcharts

SwiftChart Add Range Highlighting


I'm using the Swift Chart. I'd like to modify it to allow the user to select a range. The idea is to touch, swipe left/right, and then lift your finger. This should highlight the area swiped and provide a way to get the beginning and ending values of the swipe. I expect I'll need to modify the touchesBegan() and touchesEnded() events, but I don't know how.


Solution

  • Here's what I did to make this work:

    I added range selection variables to the class

    // Range selection
    open var leftRangePoint: UITouch!
    open var rightRangePoint: UITouch!
    open var leftRangeLocation: CGFloat = 0
    open var rightRangeLocation: CGFloat = 0
    

    I modified touchesBegan()

    leftRangePoint = touches.first!
    leftRangeLocation = leftRangePoint.location(in: self).x
    

    And added a routine to touchesEnded()

    handleRangeTouchesEnded(touches, event: event)
    

    Here's the full code:

    //  Chart.swift
    //
    //  Created by Giampaolo Bellavite on 07/11/14.
    //  Copyright (c) 2014 Giampaolo Bellavite. All rights reserved.
    import UIKit
    
    public protocol ChartDelegate: class {
        func didTouchChart(_ chart: Chart, indexes: [Int?], x: Float, left: CGFloat)
        func didFinishTouchingChart(_ chart: Chart)
        func didEndTouchingChart(_ chart: Chart)
    }
    
    typealias ChartPoint = (x: Float, y: Float)
    public enum ChartLabelOrientation {
        case horizontal
        case vertical
    }
    
    @IBDesignable open class Chart: UIControl {
        @IBInspectable
        open var identifier: String?
        open var series: [ChartSeries] = [] {
            didSet {
                setNeedsDisplay()
            }
        }
    
        open var xLabels: [Float]?
        open var xLabelsFormatter = { (labelIndex: Int, labelValue: Float) -> String in
            String(Int(labelValue))
        }
    
        open var xLabelsTextAlignment: NSTextAlignment = .left
        open var xLabelsOrientation: ChartLabelOrientation = .horizontal
        open var xLabelsSkipLast: Bool = true
        open var xLabelsSkipAll: Bool = true
        open var yLabels: [Float]?
        open var yLabelsFormatter = { (labelIndex: Int, labelValue: Float) -> String in
            String(Int(labelValue))
        }
    
        open var yLabelsOnRightSide: Bool = false
        open var labelFont: UIFont? = UIFont.systemFont(ofSize: 12)
    
        @IBInspectable
        open var labelColor: UIColor = UIColor.black
    
        @IBInspectable
        open var axesColor: UIColor = UIColor.gray.withAlphaComponent(0.3)
    
        @IBInspectable
        open var gridColor: UIColor = UIColor.gray.withAlphaComponent(0.3)
        open var showXLabelsAndGrid: Bool = true
        open var showYLabelsAndGrid: Bool = true
        open var bottomInset: CGFloat = 20
        open var topInset: CGFloat = 20
    
        @IBInspectable
        open var lineWidth: CGFloat = 2
    
        weak open var delegate: ChartDelegate?
    
        open var minX: Float?
        open var minY: Float?
        open var maxX: Float?
        open var maxY: Float?
        open var highlightLineColor = UIColor.gray
        open var highlightLineWidth: CGFloat = 0.5
        open var areaAlphaComponent: CGFloat = 0.1
        open var leftRangePoint: UITouch!
        open var rightRangePoint: UITouch!
        open var leftRangeLocation: CGFloat = 0
        open var rightRangeLocation: CGFloat = 0
    
        fileprivate var highlightShapeLayer: CAShapeLayer!
        fileprivate var layerStore: [CAShapeLayer] = []
    
        fileprivate var drawingHeight: CGFloat!
        fileprivate var drawingWidth: CGFloat!
    
        fileprivate var min: ChartPoint!
        fileprivate var max: ChartPoint!
    
        typealias ChartLineSegment = [ChartPoint]
    
        override public init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
    
        required public init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
    
        convenience public init() {
            self.init(frame: .zero)
            commonInit()
        }
    
        private func commonInit() {
            backgroundColor = UIColor.clear
            contentMode = .redraw // redraw rects on bounds change
        }
    
        override open func draw(_ rect: CGRect) {
            #if TARGET_INTERFACE_BUILDER
                drawIBPlaceholder()
                #else
                drawChart()
            #endif
        }
    
        open func add(_ series: ChartSeries) {
            self.series.append(series)
        }
    
        open func add(_ series: [ChartSeries]) {
            for s in series {
                add(s)
            }
        }
    
        open func removeSeriesAt(_ index: Int) {
            series.remove(at: index)
        }
    
        open func removeAllSeries() {
            series = []
        }
    
        open func valueForSeries(_ seriesIndex: Int, atIndex dataIndex: Int?) -> Float? {
            if dataIndex == nil { return nil }
            let series = self.series[seriesIndex] as ChartSeries
            return series.data[dataIndex!].y
        }
    
        fileprivate func drawIBPlaceholder() {
            let placeholder = UIView(frame: self.frame)
            placeholder.backgroundColor = UIColor(red: 0.93, green: 0.93, blue: 0.93, alpha: 1)
            let label = UILabel()
            label.text = "Chart"
            label.font = UIFont.systemFont(ofSize: 28)
            label.textColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.2)
            label.sizeToFit()
            label.frame.origin.x += frame.width/2 - (label.frame.width / 2)
            label.frame.origin.y += frame.height/2 - (label.frame.height / 2)
    
            placeholder.addSubview(label)
            addSubview(placeholder)
        }
    
        fileprivate func drawChart() {
            drawingHeight = bounds.height - bottomInset - topInset
            drawingWidth = bounds.width
    
            let minMax = getMinMax()
            min = minMax.min
            max = minMax.max
    
            highlightShapeLayer = nil
    
            // Remove things before drawing, e.g. when changing orientation
            for view in self.subviews {
                view.removeFromSuperview()
            }
            for layer in layerStore {
                layer.removeFromSuperlayer()
            }
            layerStore.removeAll()
    
            // Draw content
            for (index, series) in self.series.enumerated() {
                // Separate each line in multiple segments over and below the x axis
                let segments = Chart.segmentLine(series.data as ChartLineSegment, zeroLevel: series.colors.zeroLevel)
    
                segments.forEach({ segment in
                    let scaledXValues = scaleValuesOnXAxis( segment.map({ return $0.x }) )
                    let scaledYValues = scaleValuesOnYAxis( segment.map({ return $0.y }) )
    
                    if series.line {
                        drawLine(scaledXValues, yValues: scaledYValues, seriesIndex: index)
                    }
                    if series.area {
                        drawArea(scaledXValues, yValues: scaledYValues, seriesIndex: index)
                    }
                })
            }
    
            drawAxes()
    
            if showXLabelsAndGrid && (xLabels != nil || series.count > 0) {
                drawLabelsAndGridOnXAxis()
            }
            if showYLabelsAndGrid && (yLabels != nil || series.count > 0) {
                drawLabelsAndGridOnYAxis()
            }
        }
    
        fileprivate func getMinMax() -> (min: ChartPoint, max: ChartPoint) {
            // Start with user-provided values
            var min = (x: minX, y: minY)
            var max = (x: maxX, y: maxY)
    
            // Check in datasets
            for series in self.series {
                let xValues =  series.data.map({ (point: ChartPoint) -> Float in
                    return point.x })
               let yValues =  series.data.map({ (point: ChartPoint) -> Float in
                    return point.y })
    
                let newMinX = xValues.min()!
                let newMinY = yValues.min()!
                let newMaxX = xValues.max()!
                let newMaxY = yValues.max()!
    
                if min.x == nil || newMinX < min.x! { min.x = newMinX }
                if min.y == nil || newMinY < min.y! { min.y = newMinY }
                if max.x == nil || newMaxX > max.x! { max.x = newMaxX }
                if max.y == nil || newMaxY > max.y! { max.y = newMaxY }
            }
    
            // Check in labels
            if xLabels != nil {
                let newMinX = (xLabels!).min()!
                let newMaxX = (xLabels!).max()!
                if min.x == nil || newMinX < min.x! { min.x = newMinX }
                if max.x == nil || newMaxX > max.x! { max.x = newMaxX }
            }
    
            if yLabels != nil {
                let newMinY = (yLabels!).min()!
                let newMaxY = (yLabels!).max()!
                if min.y == nil || newMinY < min.y! { min.y = newMinY }
                if max.y == nil || newMaxY > max.y! { max.y = newMaxY }
            }
    
            if min.x == nil { min.x = 0 }
            if min.y == nil { min.y = 0 }
            if max.x == nil { max.x = 0 }
            if max.y == nil { max.y = 0 }
    
            return (min: (x: min.x!, y: min.y!), max: (x: max.x!, max.y!))
        }
    
        fileprivate func scaleValuesOnXAxis(_ values: [Float]) -> [Float] {
            let width = Float(drawingWidth)
    
            var factor: Float
            if max.x - min.x == 0 {
                factor = 0
            } else {
                factor = width / (max.x - min.x)
            }
    
            let scaled = values.map { factor * ($0 - self.min.x) }
            return scaled
        }
    
        fileprivate func scaleValuesOnYAxis(_ values: [Float]) -> [Float] {
            let height = Float(drawingHeight)
            var factor: Float
            if max.y - min.y == 0 {
                factor = 0
            } else {
                factor = height / (max.y - min.y)
            }
    
            let scaled = values.map { Float(self.topInset) + height - factor * ($0 - self.min.y) }
            return scaled
        }
    
        fileprivate func scaleValueOnYAxis(_ value: Float) -> Float {
            let height = Float(drawingHeight)
            var factor: Float
            if max.y - min.y == 0 {
                factor = 0
            } else {
                factor = height / (max.y - min.y)
            }
    
            let scaled = Float(self.topInset) + height - factor * (value - min.y)
            return scaled
        }
    
        fileprivate func getZeroValueOnYAxis(zeroLevel: Float) -> Float {
            if min.y > zeroLevel {
                return scaleValueOnYAxis(min.y)
            } else {
                return scaleValueOnYAxis(zeroLevel)
            }
        }
    
        fileprivate func drawLine(_ xValues: [Float], yValues: [Float], seriesIndex: Int) {
            // YValues are "reverted" from top to bottom, so 'above' means <= level
            let isAboveZeroLine = yValues.max()! <= self.scaleValueOnYAxis(series[seriesIndex].colors.zeroLevel)
            let path = CGMutablePath()
            path.move(to: CGPoint(x: CGFloat(xValues.first!), y: CGFloat(yValues.first!)))
            for i in 1..<yValues.count {
                let y = yValues[i]
                path.addLine(to: CGPoint(x: CGFloat(xValues[i]), y: CGFloat(y)))
            }
    
            let lineLayer = CAShapeLayer()
            lineLayer.frame = self.bounds
            lineLayer.path = path
    
            if isAboveZeroLine {
                lineLayer.strokeColor = series[seriesIndex].colors.above.cgColor
            } else {
                lineLayer.strokeColor = series[seriesIndex].colors.below.cgColor
            }
            lineLayer.fillColor = nil
            lineLayer.lineWidth = lineWidth
            lineLayer.lineJoin = kCALineJoinBevel
    
            self.layer.addSublayer(lineLayer)
    
            layerStore.append(lineLayer)
        }
    
        fileprivate func drawArea(_ xValues: [Float], yValues: [Float], seriesIndex: Int) {
            // YValues are "reverted" from top to bottom, so 'above' means <= level
            let isAboveZeroLine = yValues.max()! <= self.scaleValueOnYAxis(series[seriesIndex].colors.zeroLevel)
            let area = CGMutablePath()
            let zero = CGFloat(getZeroValueOnYAxis(zeroLevel: series[seriesIndex].colors.zeroLevel))
    
            area.move(to: CGPoint(x: CGFloat(xValues[0]), y: zero))
            for i in 0..<xValues.count {
                area.addLine(to: CGPoint(x: CGFloat(xValues[i]), y: CGFloat(yValues[i])))
            }
            area.addLine(to: CGPoint(x: CGFloat(xValues.last!), y: zero))
            let areaLayer = CAShapeLayer()
            areaLayer.frame = self.bounds
            areaLayer.path = area
            areaLayer.strokeColor = nil
            if isAboveZeroLine {
                areaLayer.fillColor = series[seriesIndex].colors.above.withAlphaComponent(areaAlphaComponent).cgColor
            } else {
                areaLayer.fillColor = series[seriesIndex].colors.below.withAlphaComponent(areaAlphaComponent).cgColor
            }
            areaLayer.lineWidth = 0
    
            self.layer.addSublayer(areaLayer)
    
            layerStore.append(areaLayer)
        }
    
        fileprivate func drawAxes() {
            let context = UIGraphicsGetCurrentContext()!
            context.setStrokeColor(axesColor.cgColor)
            context.setLineWidth(0.5)
    
            // horizontal axis at the bottom
            context.move(to: CGPoint(x: CGFloat(0), y: drawingHeight + topInset))
            context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: drawingHeight + topInset))
            context.strokePath()
    
            // horizontal axis at the top
            context.move(to: CGPoint(x: CGFloat(0), y: CGFloat(0)))
            context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: CGFloat(0)))
            context.strokePath()
    
            // horizontal axis when y = 0
            if min.y < 0 && max.y > 0 {
                let y = CGFloat(getZeroValueOnYAxis(zeroLevel: 0))
                context.move(to: CGPoint(x: CGFloat(0), y: y))
                context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: y))
                context.strokePath()
            }
    
            // vertical axis on the left
            context.move(to: CGPoint(x: CGFloat(0), y: CGFloat(0)))
            context.addLine(to: CGPoint(x: CGFloat(0), y: drawingHeight + topInset))
            context.strokePath()
    
            // vertical axis on the right
            context.move(to: CGPoint(x: CGFloat(drawingWidth), y: CGFloat(0)))
            context.addLine(to: CGPoint(x: CGFloat(drawingWidth), y: drawingHeight + topInset))
            context.strokePath()
        }
    
        fileprivate func drawLabelsAndGridOnXAxis() {
            let context = UIGraphicsGetCurrentContext()!
            context.setStrokeColor(gridColor.cgColor)
            context.setLineWidth(0.5)
    
            var labels: [Float]
            if xLabels == nil {
                // Use labels from the first series
                labels = series[0].data.map({ (point: ChartPoint) -> Float in
                    return point.x })
            } else {
                labels = xLabels!
            }
    
            let scaled = scaleValuesOnXAxis(labels)
            let padding: CGFloat = 5
            scaled.enumerated().forEach { (i, value) in
                let x = CGFloat(value)
                let isLastLabel = x == drawingWidth
    
                // Add vertical grid for each label, except axes on the left and right
                if x != 0 && x != drawingWidth {
                    context.move(to: CGPoint(x: x, y: CGFloat(0)))
    
                    if xLabelsSkipAll {
                        let height: CGFloat = bounds.height - 20.0
                        context.addLine(to: CGPoint(x: x, y: height))
                    } else {
                        context.addLine(to: CGPoint(x: x, y: bounds.height))
                    }
    
                    context.strokePath()
                }
    
                if (xLabelsSkipLast && isLastLabel) || xLabelsSkipAll {
                    // Do not add label at the most right position
                    return
                }
    
                // Add label
                let label = UILabel(frame: CGRect(x: x, y: drawingHeight, width: 0, height: 0))
                label.font = labelFont
                label.text = xLabelsFormatter(i, labels[i])
                label.textColor = labelColor
    
                // Set label size
                label.sizeToFit()
                // Center label vertically
                label.frame.origin.y += topInset
                if xLabelsOrientation == .horizontal {
                    // Add left padding
                    label.frame.origin.y -= (label.frame.height - bottomInset) / 2
                    label.frame.origin.x += padding
    
                    // Set label's text alignment
                    label.frame.size.width = (drawingWidth / CGFloat(labels.count)) - padding * 2
                    label.textAlignment = xLabelsTextAlignment
                } else {
                    label.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi / 2))
    
                    // Adjust vertical position according to the label's height
                    label.frame.origin.y += label.frame.size.height / 2
    
                    // Adjust horizontal position as the series line
                    label.frame.origin.x = x
                    if xLabelsTextAlignment == .center {
                        // Align horizontally in series
                        label.frame.origin.x += ((drawingWidth / CGFloat(labels.count)) / 2) - (label.frame.size.width / 2)
                    } else {
                        // Give some space from the vertical line
                        label.frame.origin.x += padding
                    }
                }
                self.addSubview(label)
            }
        }
    
        fileprivate func drawLabelsAndGridOnYAxis() {
            let context = UIGraphicsGetCurrentContext()!
            context.setStrokeColor(gridColor.cgColor)
            context.setLineWidth(0.5)
    
            var labels: [Float]
            if yLabels == nil {
                labels = [(min.y + max.y) / 2, max.y]
                if yLabelsOnRightSide || min.y != 0 {
                    labels.insert(min.y, at: 0)
                }
            } else {
                labels = yLabels!
            }
    
            let scaled = scaleValuesOnYAxis(labels)
            let padding: CGFloat = 5
            let zero = CGFloat(getZeroValueOnYAxis(zeroLevel: 0))
    
            scaled.enumerated().forEach { (i, value) in
                let y = CGFloat(value)
    
                // Add horizontal grid for each label, but not over axes
                if y != drawingHeight + topInset && y != zero {
                    context.move(to: CGPoint(x: CGFloat(0), y: y))
                    context.addLine(to: CGPoint(x: self.bounds.width, y: y))
                    if labels[i] != 0 {
                        // Horizontal grid for 0 is not dashed
                        context.setLineDash(phase: CGFloat(0), lengths: [CGFloat(5)])
                    } else {
                        context.setLineDash(phase: CGFloat(0), lengths: [])
                    }
                    context.strokePath()
                }
    
                let label = UILabel(frame: CGRect(x: padding, y: y, width: 0, height: 0))
                label.font = labelFont
                label.text = yLabelsFormatter(i, labels[i])
                label.textColor = labelColor
                label.sizeToFit()
    
                if yLabelsOnRightSide {
                    label.frame.origin.x = drawingWidth
                    label.frame.origin.x -= label.frame.width + padding
                }
    
                // Labels should be placed above the horizontal grid
                label.frame.origin.y -= label.frame.height
    
                self.addSubview(label)
            }
            UIGraphicsEndImageContext()
        }
    
        fileprivate func drawHighlightLineFromLeftPosition(_ left: CGFloat) {
            if let shapeLayer = highlightShapeLayer {
                // Use line already created
                let path = CGMutablePath()
    
                path.move(to: CGPoint(x: left, y: 0))
                path.addLine(to: CGPoint(x: left, y: drawingHeight + topInset))
                shapeLayer.path = path
            } else {
                // Create the line
                let path = CGMutablePath()
    
                path.move(to: CGPoint(x: left, y: CGFloat(0)))
                path.addLine(to: CGPoint(x: left, y: drawingHeight + topInset))
                let shapeLayer = CAShapeLayer()
                shapeLayer.frame = self.bounds
                shapeLayer.path = path
                shapeLayer.strokeColor = highlightLineColor.cgColor
                shapeLayer.fillColor = nil
                shapeLayer.lineWidth = highlightLineWidth
    
                highlightShapeLayer = shapeLayer
                layer.addSublayer(shapeLayer)
                layerStore.append(shapeLayer)
            }
        }
    
        func handleTouchEvents(_ touches: Set<UITouch>, event: UIEvent!) {
            let point = touches.first!
            let left = point.location(in: self).x
            let x = valueFromPointAtX(left)
    
            if left < 0 || left > (drawingWidth as CGFloat) {
                // Remove highlight line at the end of the touch event
                if let shapeLayer = highlightShapeLayer {
                    shapeLayer.path = nil
                }
                delegate?.didFinishTouchingChart(self)
                return
            }
    
            drawHighlightLineFromLeftPosition(left)
    
            if delegate == nil {
                return
            }
    
            var indexes: [Int?] = []
    
            for series in self.series {
                var index: Int? = nil
                let xValues = series.data.map({ (point: ChartPoint) -> Float in
                    return point.x })
                let closest = Chart.findClosestInValues(xValues, forValue: x)
                if closest.lowestIndex != nil && closest.highestIndex != nil {
                    // Consider valid only values on the right
                    index = closest.lowestIndex
                }
                indexes.append(index)
            }
    
            delegate!.didTouchChart(self, indexes: indexes, x: x, left: left)
        }
    
        override open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            handleTouchEvents(touches, event: event)
    
            leftRangePoint = touches.first!
            leftRangeLocation = leftRangePoint.location(in: self).x
        }
    
        override open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            handleTouchEvents(touches, event: event)
            delegate?.didEndTouchingChart(self)
    
            handleRangeTouchesEnded(touches, event: event)
        }
    
        func handleRangeTouchesEnded(_ touches: Set<UITouch>, event: UIEvent!) {
            rightRangePoint = touches.first!
            rightRangeLocation = rightRangePoint.location(in: self).x
    
            // Make sure left is actually to the left
            if rightRangeLocation < leftRangeLocation {
                let rangePoint = leftRangePoint
                let rangeLocation = leftRangeLocation
                leftRangePoint = rightRangePoint
                leftRangeLocation = rightRangeLocation
                rightRangePoint = rangePoint
                rightRangeLocation = rangeLocation
            }
    
            // Highlight the range
            let layer = CAShapeLayer()
            let width = rightRangeLocation - leftRangeLocation
            layer.path = UIBezierPath(rect: CGRect(x: leftRangeLocation, y: topInset, width: width, height: drawingHeight)).cgPath
    
            layer.fillColor = UIColor.red.cgColor
            layer.opacity = 0.3
            self.layer.addSublayer(layer)
        }
    
        override open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            handleTouchEvents(touches, event: event)
        }
    
        fileprivate func valueFromPointAtX(_ x: CGFloat) -> Float {
            let value = ((max.x-min.x) / Float(drawingWidth)) * Float(x) + min.x
            return value
        }
    
        fileprivate func valueFromPointAtY(_ y: CGFloat) -> Float {
            let value = ((max.y - min.y) / Float(drawingHeight)) * Float(y) + min.y
            return -value
        }
    
        fileprivate class func findClosestInValues(_ values: [Float],
            forValue value: Float
    ) -> (
                lowestValue: Float?,
                highestValue: Float?,
                lowestIndex: Int?,
                highestIndex: Int?
            ) {
            var lowestValue: Float?, highestValue: Float?, lowestIndex: Int?, highestIndex: Int?
    
            values.enumerated().forEach { (i, currentValue) in
    
                if currentValue <= value && (lowestValue == nil || lowestValue! < currentValue) {
                    lowestValue = currentValue
                    lowestIndex = i
                }
                if currentValue >= value && (highestValue == nil || highestValue! > currentValue) {
                    highestValue = currentValue
                    highestIndex = i
                }
            }
            return (
                lowestValue: lowestValue,
                highestValue: highestValue,
                lowestIndex: lowestIndex,
                highestIndex: highestIndex
            )
        }
    
        fileprivate class func segmentLine(_ line: ChartLineSegment, zeroLevel: Float) -> [ChartLineSegment] {
            var segments: [ChartLineSegment] = []
            var segment: ChartLineSegment = []
    
            line.enumerated().forEach { (i, point) in
                segment.append(point)
                if i < line.count - 1 {
                    let nextPoint = line[i+1]
                    if point.y >= zeroLevel && nextPoint.y < zeroLevel || point.y < zeroLevel && nextPoint.y >= zeroLevel {
                        // The segment intersects zeroLevel, close the segment with the intersection point
                        let closingPoint = Chart.intersectionWithLevel(point, and: nextPoint, level: zeroLevel)
                        segment.append(closingPoint)
                        segments.append(segment)
                        // Start a new segment
                        segment = [closingPoint]
                    }
                } else {
                    // End of the line
                    segments.append(segment)
                }
            }
            return segments
        }
    
        fileprivate class func intersectionWithLevel(_ p1: ChartPoint, and p2: ChartPoint, level: Float) -> ChartPoint {
            let dy1 = level - p1.y
            let dy2 = level - p2.y
            return (x: (p2.x * dy1 - p1.x * dy2) / (dy1 - dy2), y: level)
        }
    }