Program crash after trying to draw chart

94 Views Asked by At

I have an app, where user can select date (from/to), list of currencies, and then, display chart with some data. The chart, is just stacked bar chart, from MPAndroidChart. Recently, I've implemented SwiperRefreshLayout, so user can make more than just one api call. There's two cases of displaying chart:

First (everything is working fine) When user will select for example 2 currencies, he could make another calls, but he can't exceed firstly picked amount of currencies. So in this case, he can pick one, or two currencies. No matter which ones.

Second, program crashes User will pick X amount of currencies, the chart will be displayed, and after refreshing layout, user will pick X+1 currencies. This makes the program crash.

Since I've spend already a couple of days with this issue, I've tried to clear data, invalidating chart, etc. It turns out, that this command makes the program crash

mBinding.myChart.setCustom(myEntries)

I can run entire program without it, but it is needed, since it provides labels for chart.

Here's some code:

Global variables

    private var myChartData: MutableList<BarEntry> = mutableListOf()
    private var set: BarDataSet? = null

API call

    private val apiCall: Job
    get() =
        viewLifecycleOwner.lifecycleScope.launch(start = CoroutineStart.LAZY) {

            for (i in 0 until pickedCurrs.size) {
                myPickedCurrencies += "${pickedCurrs[i]}, "
            }

            mViewModel.fetchData(
                myBaseCurrency,
                myPickedCurrencies,
                fromDate,
                toDate
            )

            mViewModel.myApiData.observe(viewLifecycleOwner, Observer { status ->
                when (status) {
                    is MyWrapper.Success -> {
                        status.data?.timeSeriesRates?.entries?.forEachIndexed { index, entry ->
                            myChartData.add(
                                BarEntry(
                                    index.toFloat(),
                                    entry.value.values.map { it.toFloat() }.toFloatArray()
                                )
                            )
                        }
                        makeChart(
                            myChartData,
                            formatDate(status.data?.timeSeriesRates?.keys!!.toList()),
                            mSelectedCurrencies
                        )
                    }
                    is MyWrapper.Error -> {
                        Log.i(TAG, "onCreateView: ERROR")
                    }
                }
            })
        }

makeChart

      private fun prepareChart(
        entries: MutableList<BarEntry>,
        xAxisValues: ArrayList<String>,
        pickedCurrs: MutableList<String>
    ) {
        val colorsList = mutableListOf<Int>()
        val legEn = mutableListOf<LegendEntry>()

        for (i in pickedCurrs.indices) {
            val color =
                Color.argb(
                    255,
                    Random().nextInt(256),
                    Random().nextInt(256),
                    Random().nextInt(256)
                )
            if (!colorsList.contains(color)) {
                colorsList.add(color)
            }
        }
        set = BarDataSet(myEntries, "x")
        set?.colors = colorsList
        set?.highLightAlpha = 0

        val data = BarData(set)
        mBinding.timeSeriesChart.data = data
        mBinding.timeSeriesChart.description?.isEnabled = false
        mBinding.timeSeriesChart.setVisibleXRangeMaximum(6f)
        mBinding.timeSeriesChart.barData?.setValueTextSize(12f)
        mBinding.timeSeriesChart.xAxis?.position = XAxis.XAxisPosition.BOTTOM
        mBinding.timeSeriesChart.setExtraOffsets(0f, 0f, 0f, 15f)
//
//        //xAxis
        mBinding.timeSeriesChart.xAxis?.setDrawLabels(true)
        mBinding.timeSeriesChart.xAxis?.position = XAxis.XAxisPosition.BOTTOM_INSIDE
        mBinding.timeSeriesChart.xAxis?.granularity = 1f
        mBinding.timeSeriesChart.xAxis?.textSize = 11.5f
        mBinding.timeSeriesChart.xAxis?.valueFormatter = IndexAxisValueFormatter(myXAxisValues)
        mBinding.timeSeriesChart.xAxis?.labelRotationAngle = -20f
        mBinding.timeSeriesChart.axisRight?.setDrawGridLines(false)
        mBinding.timeSeriesChart.axisLeft.isEnabled = false
        mBinding.timeSeriesChart.axisRight.isEnabled = false
//
//        //legend
        val legend = mBinding.timeSeriesChart.legend
        legend?.verticalAlignment = Legend.LegendVerticalAlignment.BOTTOM
        legend?.horizontalAlignment = Legend.LegendHorizontalAlignment.LEFT
        legend?.orientation = Legend.LegendOrientation.HORIZONTAL
        legend?.setDrawInside(false)


        for (i in pickedCurrs.indices) {
            legEn.add(
                LegendEntry(
                    pickedCurrs[i],
                    Legend.LegendForm.SQUARE,
                    15f,
                    15f,
                    null,
                    colorsList[i]
                )
            )
        }
       
        legend?.setCustom(legEn)

        set?.valueFormatter = object : ValueFormatter() {
            override fun getFormattedValue(value: Float): String {
                return String.format("%2.02f", value)
            }
        }
        mBinding.timeSeriesChart.invalidate()
        mBinding.timeSeriesChart.refreshDrawableState()
    }

EDIT stack trace

Process: com.example.x, PID: 15091
    java.lang.IndexOutOfBoundsException: Index 1 out of bounds for length 1
        at jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:64)
        at jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:70)
        at jdk.internal.util.Preconditions.checkIndex(Preconditions.java:266)
        at java.util.Objects.checkIndex(Objects.java:359)
        at java.util.ArrayList.get(ArrayList.java:434)
        at com.github.mikephil.charting.renderer.LegendRenderer.renderLegend(LegendRenderer.java:377)
        at com.github.mikephil.charting.charts.BarLineChartBase.onDraw(BarLineChartBase.java:281)
        at android.view.View.draw(View.java:23889)
        at android.view.View.updateDisplayListIfDirty(View.java:22756)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at androidx.constraintlayout.widget.ConstraintLayout.dispatchDraw(ConstraintLayout.java:1994)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at android.view.View.draw(View.java:23892)
        at android.view.View.updateDisplayListIfDirty(View.java:22756)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at androidx.constraintlayout.widget.ConstraintLayout.dispatchDraw(ConstraintLayout.java:1994)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at androidx.recyclerview.widget.RecyclerView.drawChild(RecyclerView.java:5545)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at android.view.View.draw(View.java:23892)
        at androidx.recyclerview.widget.RecyclerView.draw(RecyclerView.java:4944)
        at android.view.View.updateDisplayListIfDirty(View.java:22756)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at androidx.fragment.app.FragmentContainerView.drawChild(FragmentContainerView.kt:235)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at androidx.fragment.app.FragmentContainerView.dispatchDraw(FragmentContainerView.kt:225)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at androidx.fragment.app.FragmentContainerView.drawChild(FragmentContainerView.kt:235)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at androidx.fragment.app.FragmentContainerView.dispatchDraw(FragmentContainerView.kt:225)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
        at android.view.View.updateDisplayListIfDirty(View.java:22747)
        at android.view.View.draw(View.java:23620)
        at android.view.ViewGroup.drawChild(ViewGroup.java:4556)
        at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4317)
1

There are 1 best solutions below

0
Tyler V On

TL;DR Call chart.notifyDataSetChanged() after calling legend.setCustom(entries)

Unfortunately I think this is a bug in MPAndroidChart. It will hit that crash if you increase the number of entries in a custom legend with notifying the chart that the dataset changed. I made a simple example that replicates this issue to test the fix.

If you run this demo problem and click the "Add Entry" button, it will crash with the same error you saw.

class MainActivity : AppCompatActivity() {

    private var entries = mutableListOf(
        BarEntry(0f, 3f),
        BarEntry(1f,2.5f),
        BarEntry(2f,4f)
    )
    private var labels = mutableListOf("Foo","Bar","Baz")

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val chart = findViewById<BarChart>(R.id.chart)
        val addEntry = findViewById<Button>(R.id.add_entry)

        addEntry.setOnClickListener {
            entries.add(BarEntry(entries.size.toFloat(), 3.2f))
            labels.add("Xtra")
            refreshChart(chart)
        }

        refreshChart(chart)
    }


    private fun refreshChart(chart: BarChart) {
        val colors = entries.map {
            Color.argb(
                255,
                Random.nextInt(256),
                Random.nextInt(256),
                Random.nextInt(256)
            )
        }
        val ds = BarDataSet(entries, "x")
        ds.colors = colors
        chart.data = BarData(ds)

        val legend = chart.legend
        legend.setDrawInside(false)
        val legendEntries = labels.zip(colors).map {lab_col ->
            LegendEntry(lab_col.first, Legend.LegendForm.SQUARE, 15f, 15f, null, lab_col.second)
        }

        legend.setCustom(legendEntries)
        chart.invalidate()
    }
}

The reason is that the array of calculated legend text widths in the Legend object (calculatedLabelSizes) isn't automatically updated to the new increased number of entries before the legend is rendered, leading to an IndexOutOfBoundsException in the LegendRenderer. The fix is to call notifyDataSetChanged on the chart to force it to re-measure the legend and resize that internal array to match the number of legend entries before it is rendered, like this

legend.setCustom(legendEntries)

// This forces the legend to resize its size array to match the size
// of the newly set legendEntries array
chart.notifyDataSetChanged()

chart.invalidate()        

Doing this, the demo works fine and new legend entries can be added without an issue.

working demo

Note

Your if (!colorsList.contains(color)) { clause also has the potential to cause your app to crash. If your random color generator ever hits a duplicate, the colorsList length will be too short. This is pretty unlikely, but not impossible.