56

Enhancing a Kotlin Chart With Advanced Charting Kit (Part 2)

 5 years ago
source link: https://www.tuicool.com/articles/hit/ze632ev
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

In part 1 of this blog series, I demonstrated how the Advanced Charting Kit can be used to enhance a shinobichart using Kotlin. In this blog post, I’d like to show you how you can improve your chart a little further. Again, I’ll demonstrate this using the heart rate chart that I used in part 1. At the end of part 1, we had a chart that looked as below:

bim6beq.png!web

While the chart now shows pace as well as heart rate, I feel it looks a little crowded. The relationships between the data series are not immediately obvious and I feel — to some degree — adding the pace data clutters the chart when it is viewed fully zoomed out. Rather than display all of the pace data upon the initial loading of the chart, I think this could be a great fit for ACK’s drill down animation feature. Why don’t we have the pace data appear as the user zooms in on the x or time axis?

I begin by hiding the pace series and telling them initially not to show it in the legend:

with(msSeries) {
    visibility = Series.INVISIBLE 
    isShownInLegend = false..

The reverse y-axis serves no purpose without the pace series, so I also modify its style to hide the tick marks and labels. By setting the width of the pace y-axis in advance, I provide it with enough space to show the tick marks and labels, thus avoiding a ‘jump’ as they appear.

return NumberAxis(NumberRange(-35.0, -3.0)).apply {
    width = 50f * resources.displayMetrics.scaledDensity
    getStyle().tickStyle.setLabelsShown(false)
    getStyle().tickStyle.setMajorTicksShown(false)..

To show pace data as the user zooms in, the main concept to grasp is that of OnRangeChangeListener s on the x-axis. These will listen for changes in the visible data range; as the user zooms in, the listeners will react, causing several actions to occur:

  • At an approximate data range of 3 hours, the pace series will begin to animate into view
  • At an approximate data range of 90 minutes, the pace series will be completely visible
  • At this point, the pace series will appear in the legend
  • The tick marks and labels on the reverse y-axis will appear
  • As the user zooms out, this process will operate in reverse

If I want the pace series visible as the user zooms in, I had better enable zooming on the x-axis:

return DateTimeAxis().apply {
    enableGesturePanning(true)
    enableGestureZooming(true)
}

As the user zooms into the chart, I think it would be nice if the pace series appeared with a fade animation. To help with this, I created a SeriesAnimationCreator . This object will provide the correct Animation objects on demand, which in turn will animate the series into and out of view as appropriate by manipulating its alpha property:

private fun createSeriesAnimationCreator(): SeriesAnimationCreator<Float, Float> {
    return object : SeriesAnimationCreator<Float, Float> {
        val fadeAnimationCreator = FadeAnimationCreator()
        override fun createExitAnimation(series: Series<*>?): Animation<Float> {
            return fadeAnimationCreator.createExitAnimation(series)
        }
        override fun createEntryAnimation(series: Series<*>?): Animation<Float> {
            return fadeAnimationCreator.createEntryAnimation(series)
        }
    }
}

Now that I have Animation objects available on demand, I need something that will run them. The AxisSpanAnimationRunner is the tool for the job:

private fun createAxisSpanAnimationRunner(lineSeries: LineSeries,
                                          seriesAnimationCreator:
                                          SeriesAnimationCreator<Float, Float>):
        AxisSpanAnimationRunner {
    return AxisSpanAnimationRunner.builder(lineSeries)
            .withInAnimation(seriesAnimationCreator.createEntryAnimation(lineSeries))
            .withOutAnimation(seriesAnimationCreator.createExitAnimation(lineSeries))
            .withUpperTransition(DateFrequency(3, DateFrequency.Denomination.HOURS),
                    DateFrequency(90, DateFrequency.Denomination.MINUTES))
            .build()
}

The AxisSpanAnimationRunner implements the AxisOnRangeChangeListener interface. Once set, it will monitor the current range of the x-axis and run the animations as necessary. Specifically, in this case, as you can see above, the entry animation (fade in) will begin when the visible range of the x-axis is three hours and be completed when it is zoomed in to 90 minutes. The exit animation will begin when the visible range of the x-axis is 90 minutes and complete when it is three hours. This latter scenario is what happens as a user zooms back out — the pace series will slowly fade out to become invisible.

To enable this functionality, I add the AxisSpanAnimationRunner as an OnRangeChangeListener on the x-axis. This code is simple:

with(xAxis) {
    addOnRangeChangeListener(createAxisSpanAnimationRunner(msMorningSeries,
            createSeriesAnimationCreator()))..

I repeat this code for each pace series, but there is still a little more work to do. Remember that I also need to set the visibility of the pace series within the legend; they must only show in the legend when they are visible. I want to apply similar logic to the visibility of the tick marks and labels for the reverse y (pace) axis. To achieve this, the approach is similar to that above as I use another OnRangeChangeListener implementation:

fun createLegendAndPaceTickmarkUpdater(shinobiChart: ShinobiChart):
        Axis.OnRangeChangeListener {
    return object : Axis.OnRangeChangeListener {
        override fun onRangeChange(axis: Axis<*, *>?) {
            updateLegendAndTicks()
            shinobiChart.redrawChart()
        }
        private fun updateLegendAndTicks() {
            val visible = seriesIsVisible(shinobiChart.series[1])
            val style = shinobiChart.getYAxisForSeries(shinobiChart.series[1]).getStyle()
            style.tickStyle.setMajorTicksShown(visible)
            style.tickStyle.setLabelsShown(visible)
            shinobiChart.series[1].isShownInLegend = visible
        }
        private fun seriesIsVisible(series: Series<*>): Boolean {
            return series.visibility == View.VISIBLE && series.alpha == 1.0f
        }
    }
}

As you can see, the new object will monitor the visibility of the pace series and show or hide the tick marks and labels of the pace axis. It will also set the pace series to be shown in the legend when it is visible on the chart. You may have spotted that when checking for series visibility, I only look at the first pace series or the series at index position [1]. This is deliberate; each pace series has the same name and they are all intrinsically linked. It only makes sense for all three pace series to be visible or invisible together, and as such, I only need to check the visibility of the first one. In a similar fashion, as they all share the same name and style, I only need to set the first to be shown in the legend. As before adding the final OnRangeChangeListener to the x-axis requires very little code:

addOnRangeChangeListener(createLegendAndPaceTickmarkUpdater(shinobiChart))

The chart is really starting to take shape! Now, when fully zoomed out, it shows the heart rate data only but gains clarity in doing so. As we zoom in, if we view one of the activities (the spikes in the heart rate data), the pace series fades into view, offering more data for detailed analysis.

device-2019-01-14-163415-1024x792.png

You’ll notice in the paragraph above that I felt the need to associate ‘spike’ with ‘activity.' This raises the valid point that the activities are not particularly obvious. I think I can fix this by adding Annotation s to the chart.

Prior to adding the annotations, I needed some way to keep track of the beginning and end boundaries of each activity. The solution was to use a data class named ActivityStartEndDatePair, which also keeps track of the resId for the appropriate icon, which will be used for the ViewAnnotation . For example, a ‘stick man walking’ icon is used for the walking activity. Activities are added to a list of ActivityStartEndDatePair s by a registration function:

fun registerActivities(vararg activities: Pair<ActivityType, DataAdapter<Date, Double>>) {
    for (activity in activities) {
        registerActivity(activity.first, activity.second)
    }
}
private fun registerActivity(activityType: ActivityType,
                             dataAdapter: DataAdapter<Date, Double>) {
    //get date (x) of first and last data point
    activityStartEndDatePairs.add(ActivityStartEndDatePair(dataAdapter.get(0).x,
            dataAdapter.get(dataAdapter.size() - 1).x,
            if (activityType == ActivityType.WALK) R.drawable.ic_walk_round else R.drawable
                    .ic_run_round))
}

With the activities registered, adding the BandAnnotation s was the simplest of the two tasks:

fun addBandAnnotations(annotationsManager: AnnotationsManager,
                       xAxis: DateTimeAxis,
                       yAxis: NumberAxis,
                       context: Context) {
    for (item in activityStartEndDatePairs) {
        addBandAnnotation(annotationsManager, xAxis, yAxis,
                item.startDate,
                item.endDate,
                context)
    }
}

As you can see, I work through each activity, using their start and end times to instruct the AnnotationsManager to add a BandAnnotation to the chart.

Adding the ViewAnnotation s proved a little more tricky. I want each annotation to be positioned at the same (fixed) height but to have a diameter such that it does not venture beyond the boundaries of the BandAnnotation . I also want it centered within the band. Of course, the bands have different pixel widths depending upon the orientation of the device screen. The approach on which I decided was to calculate the image size and add the annotations when the Activity is created and remove them when it is destroyed. This caters for the different sizes depending upon orientation and avoids duplicate annotations stacking on top of one another when the device is rotated.

I only want to calculate the image size once, so after declaring a data class to hold the sizes for portrait and landscape, I calculate the correct size for the current orientation:

if (!maxImagePixelSizes.isSet()) {
    calculateMaxViewAnnotationPixelSizes(getViewAnnotationPixelSize(
            activityStartEndDatePairs[0].startDate,
            activityStartEndDatePairs[0].endDate,
            xAxis),
            orientationStrategy,
            windowManager)
}
private fun getViewAnnotationPixelSize(startDate: Date,
                                       endDate: Date,
                                       xAxis: DateTimeAxis): Int {

    return ((xAxis.getPixelValueForUserValue(endDate) -
            xAxis.getPixelValueForUserValue(startDate)) * .8).toInt()
}

Note the use of the start and end dates of the first (index 0) activity. The image must fit into the activity with the narrowest band, which I know is either of my walks; in production code, I would expect this to be calculated.

Once I have this size value, I use the current device ratio to help me calculate the size for the other screen orientation. To avoid littering the code with if statements, I employ the strategy pattern. For example, given a calculated size for the image when in portrait orientation, I calculate the landscape value like this:

pixelSizes.landscape = (currentSize / ratio).toInt()

In order to calculate the image size, I need to measure elements of the chart; for example, I need to know the width of the bands in pixel terms — these calculations return zero until the chart has been measured and laid out. Luckily, the chart has a handy OnInternalLayoutListener that allows me to add the annotations after the chart has been laid out. I start by declaring that MainActivity implements this:

class MainActivity : ShinobiChart.OnInternalLayoutListener,..

This is a good time to set MainActivity as the chart’s OnInternalLayoutListener :

shinobiChart.setOnInternalLayoutListener(this)

I then implement the onInternalLayout function as follows:

override fun onInternalLayout(chart: ShinobiChart?) {
    addViewAnnotations(shinobiChart.annotationsManager,
            shinobiChart.xAxis as DateTimeAxis,
            shinobiChart.yAxis as NumberAxis,
            maxImagePixelSizes,
            orientationStrategy,
            windowManager,
            applicationContext,
            viewAnnotations)
    shinobiChart.setOnInternalLayoutListener(null)
}

You’ll notice that I set this listener on the chart to null at the end of this function. To avoid duplicate annotations being added each time the Activity is created (for example following a screen rotation) it makes sense to create them only once. This function is called every time the chart is laid out and, as such, could become quite expensive if left attached as an OnInternalLayoutListener.

With the image size calculated, asking the AnnotationsManager to add ViewAnnotation s is simple:

val annotationView = ImageView(context).apply {
    layoutParams = ViewGroup.LayoutParams(size, size)
    setImageResource(resourceId)
}
viewAnnotations.add(annotationsManager.addViewAnnotation(annotationView,
        getActivityTimeMidPoint(startDate, endDate), 150.0,
        xAxis,
        yAxis))

Note that I add each annotation to a list — this allows me to remove the annotations when the Activity is destroyed:

override fun onDestroy() {
    super.onDestroy()
    for (item in viewAnnotations) {
        shinobiChart.annotationsManager.removeAnnotation(item)
    }
}

The finished chart — when zoomed out — should look something like below:

QVfIJzm.png!web

I’ve kept things fairly simple by adding a BandAnnotation and a ViewAnnotation for each of the three activities. The shinobicharts annotations API is flexible so you could, of course, be a little more adventurous if you are eager to demonstrate your creativity! With a little styling, the chart is now more useful and pleasant to look at, but more importantly, the walk and run activities are much more obvious.

You can take a look at the finished code yourself here . You may also find this video useful, which demonstrates the drill down animation concept in a little more detail, including the code. While it is based in Java, the syntax is similar.

ACK comes with comprehensive how-to guides, which demonstrate in detail how to achieve the concepts discussed. You can find these in the download bundle or here.

In part 1 and part 2 of this blog, I’ve shown how you can use ACK to enhance data visualized by shinobicharts using Kotlin. We hope you will experiment by using shinobicharts and ACK within your own apps. If you have not already done so, why not download a free 30-day trial? If you have any questions, feel free to get in touch.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK