Search code examples
iosswiftchartsios-charts

How to render only chosen axis labels in ios Charts


Library I'm using: charts
I have line chart with values at specific days in year. I don't want to draw each day as label on x axis (there may be more than 300 days range) so I'm looking to draw months only. Right now, my xVals looks like: [nil, nil, nil, "07/2016", nil nil nil (..)]. Unfortunately, no label are displayed.
Is there any other way to achieve desired behaviour?

Code (x axis values):

while date.compare(to) != NSComparisonResult.OrderedDescending { // iterate over all days in range
        let components = currentCalendar.components([.Day, .Month , .Year], fromDate: date)
        if components.day != 1 {
            vals.append(nil)
        } else { // first day of the month
            vals.append(String(components.month) + " / " + String(components.year))
        }

        let incrementComponents = NSDateComponents()
        incrementComponents.day = 1
        date = currentCalendar.dateByAddingComponents(incrementComponents, toDate: date, options: [])!
}

And entries:

for point in base!.points {
        if let index = chartValuesFormatter?.indexFromDate(point.date) {
            entries.append(ChartDataEntry(value: point.value, xIndex: index))
        }
}

Generally it works, when I specify all values some of them are displayed and entries are rendered correctly. My only problem is to display only month labels on the beginning of month periods.


Solution

  • Ok, so I've found solution. It's not as simple as is should (for such basic problem), but is very customizable. LineChartView (via BarLineChartViewBase) has property xAxisRenderer. You can subclass ChartXAxisRenderer which is default one, and override drawLabels function. In my case, I've copied original code and modified logic behind calculation what label and where should be drawn.

    Edit: My custom renderer with overlap logic. Probably overcomplicated, but it works so far.

    class ChartXAxisDateRenderer: ChartXAxisRenderer {
    internal static let FDEG2RAD = CGFloat(M_PI / 180.0)
    
    /// draws the x-labels on the specified y-position
    override func drawLabels(context context: CGContext, pos: CGFloat, anchor: CGPoint) {
    
        guard let xAxis = xAxis else { return }
    
        let paraStyle = NSParagraphStyle.defaultParagraphStyle().mutableCopy() as! NSMutableParagraphStyle
        paraStyle.alignment = .Center
    
        let labelAttrs = [NSFontAttributeName: xAxis.labelFont,
                          NSForegroundColorAttributeName: xAxis.labelTextColor,
                          NSParagraphStyleAttributeName: paraStyle]
        let labelRotationAngleRadians = xAxis.labelRotationAngle * ChartXAxisDateRenderer.FDEG2RAD
    
        let valueToPixelMatrix = transformer.valueToPixelMatrix
    
        let minLabelsMargin: CGFloat = 4.0
    
        var position = CGPoint(x: 0.0, y: 0.0)
    
        var labelMaxSize = CGSize()
    
        if (xAxis.isWordWrapEnabled) {
            labelMaxSize.width = xAxis.wordWrapWidthPercent * valueToPixelMatrix.a
        }
    
        var positions = [CGPoint]()
        var widths = [CGFloat]()
        var labels = [String]()
        var originalIndices = [Int]()
    
        for i in 0...xAxis.values.count-1 {
            let label = xAxis.values[i]
            if (label == nil || label == "")
            {
                continue
            }
    
            originalIndices.append(i)
            labels.append(label!)
    
            position.x = CGFloat(i)
            position.y = 0.0
            position = CGPointApplyAffineTransform(position, valueToPixelMatrix)
            positions.append(position)
    
            let labelns = label! as NSString
            let width = labelns.boundingRectWithSize(labelMaxSize, options: .UsesLineFragmentOrigin, attributes: labelAttrs, context: nil).size.width
            widths.append(width)
        }
    
        let newIndices = findBestPositions(positions, widths: widths, margin: minLabelsMargin)
    
        for index in newIndices {
            let label = labels[index]
            let position = positions[index]
            let i = originalIndices[index]
    
            if (viewPortHandler.isInBoundsX(position.x)) {
                drawLabel(context: context, label: label, xIndex: i, x: position.x, y: pos, attributes: labelAttrs, constrainedToSize: labelMaxSize, anchor: anchor, angleRadians: labelRotationAngleRadians)
            }
        }
    }
    
    // Best position indices - minimum "n" without overlapping
    private func findBestPositions(positions: [CGPoint], widths: [CGFloat], margin: CGFloat) -> [Int] {
        var n = 1
        var overlap = true
    
        // finding "n"
        while n < widths.count && overlap {
            overlap = doesOverlap(n, positions: positions, widths: widths, margin: margin)
            if overlap {
                n += 1
            }
        }
    
        var newPositions = [Int]()
        var i = 0
        // create result indices
        while i < positions.count {
            newPositions.append(i)
            i += n
        }
    
        return newPositions
    }
    
    // returns whether drawing only n-th labels will casue overlapping
    private func doesOverlap(n: Int, positions: [CGPoint], widths: [CGFloat], margin: CGFloat) -> Bool {
        var i = 0
        var newPositions = [CGPoint]()
        var newWidths = [CGFloat]()
    
        // getting only n-th records
        while i < positions.count {
            newPositions.append(positions[i])
            newWidths.append(widths[i])
            i += n
        }
    
        // overlap with next label checking
        for j in 0...newPositions.count - 2 {
            if newPositions[j].x + newWidths[j] + margin > newPositions[j+1].x {
                return true
            }
        }
    
        return false
    }
    

    }