androidmpandroidchart

MPAndroidChart: Dates on x-axis - sticky month and year


I would like to achieve a chart similar to the one from the Loop - Habit Tracker app: While scrolling horizontally, the month(and year) sticks to the left of the chart and the first of each month is replaced with the month name.

Is this possible with MPAndroidChart or any other library?

enter image description here


Solution

  • It's a little hacky, but I was able to get it to work by putting a TextView in the sticky label position and letting the AxisValueFormatter set the text value. You have no guarantee that the first of the month will actually be an axis label, so I set it so that the first day shown in a given month is replaced by the month name. This takes advantage of the fact that the axis value formatter always starts from the lowest value and move up.

    Here is a complete example showing this approach (implement it and try scrolling)

    Example view

    The activity layout xml:

    <?xml version="1.0" encoding="utf-8"?>
    <android.support.constraint.ConstraintLayout 
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/chart_layout"
        tools:context=".MainActivity">
    
        <com.github.mikephil.charting.charts.LineChart
            android:id="@+id/test_chart"
            android:layout_width="0dp"
            android:layout_marginRight="16dp"
            android:layout_marginEnd="16dp"
            android:layout_height="0dp"
            android:layout_margin="32dp"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent" />
    
        <TextView
            android:id="@+id/sticky_label"
            android:background="#ffffff"
            android:textColor="#000000"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
    
    </android.support.constraint.ConstraintLayout>
    

    The activity onCreate method:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    
        final ConstraintLayout layout = findViewById(R.id.chart_layout);
        final LineChart chart = findViewById(R.id.test_chart);
        final TextView sticky = findViewById(R.id.sticky_label);
    
        List<Entry> data = new ArrayList<>();
        for(int i = 0; i < 400; ++i) {
            data.add(new Entry(2f*i, (float)Math.sin(0.1f*i)));
        }
    
        LineDataSet lds = new LineDataSet(data,"data");
        lds.setCircleColor(Color.BLACK);
        lds.setCircleRadius(6f);
        lds.setCircleHoleRadius(3f);
        lds.setCircleColorHole(Color.WHITE);
        lds.setColor(Color.BLACK);
        lds.setLineWidth(4f);
        LineData ld = new LineData(lds);
        ld.setDrawValues(false);
        chart.setData(ld);
    
        final float textSize = 20f;
        final XAxis xa = chart.getXAxis();
        xa.setGranularity(1f);
        xa.setGranularityEnabled(true);
        xa.setValueFormatter(new StickyDateAxisValueFormatter(chart, sticky));
        xa.setPosition(XAxis.XAxisPosition.BOTTOM);
        xa.setTextSize(textSize);
        xa.setDrawGridLines(true);
    
        chart.setPinchZoom(false);
        chart.zoom(28f,1f,0f,0f);
        sticky.setTextSize(textSize);
    
        ViewTreeObserver vto = layout.getViewTreeObserver();
        vto.addOnGlobalLayoutListener (new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                // use removeGlobalOnLayoutListener prior to API 16
                layout.getViewTreeObserver().removeOnGlobalLayoutListener(this);
    
                float xo = xa.getXOffset();
                float yo = xa.getYOffset();
                final DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
                float rho = displayMetrics.density;
    
                // WARNING: The 2.2 here was calibrated to the screen I was using. Make sure it works
                // on all resolutions and densities. There may be a pixel/dp inconsistency in here somewhere.
                // The 10f is from the extra bottom offset (set below).
                float ty = chart.getY() + chart.getMeasuredHeight() - rho*yo - 2.2f*textSize - 10f;
                float tx = chart.getX() + rho*xo;
    
                sticky.setTranslationY(ty);
                sticky.setTranslationX(tx);
            }
        });
    
        sticky.setGravity(Gravity.CENTER_HORIZONTAL|Gravity.TOP);
    
        chart.getAxisLeft().setTextSize(textSize);
        chart.setExtraBottomOffset(10f);
    
        chart.getAxisRight().setEnabled(false);
        Description desc = new Description();
        desc.setText("");
        chart.setDescription(desc);
        chart.getLegend().setEnabled(false);
        chart.getAxisLeft().setDrawGridLines(true);
    }
    

    And the axis value formatter:

    public class StickyDateAxisValueFormatter implements IAxisValueFormatter {
    
        private Calendar c;
        private LineChart chart;
        private TextView sticky;
        private float lastFormattedValue = 1e9f;
        private int lastMonth = 0;
        private int lastYear = 0;
        private int stickyMonth = -1;
        private int stickyYear = -1;
        private SimpleDateFormat monthFormatter = new SimpleDateFormat("MMM", Locale.getDefault());
    
    
        StickyDateAxisValueFormatter(LineChart chart, TextView sticky) {
            c = new GregorianCalendar();
            this.chart = chart;
            this.sticky = sticky;
        }
    
        @Override
        public String getFormattedValue(float value, AxisBase axis) {
    
            // Sometimes this gets called on values much lower than the visible range
            // Catch that here to prevent messing up the sticky text logic
            if( value < chart.getLowestVisibleX() ) {
                return "";
            }
    
            // NOTE: I assume for this example that all data is plotted in days
            // since Jan 1, 2018. Update for your scheme accordingly.
    
            int days = (int)value;
    
            boolean isFirstValue = value < lastFormattedValue;
    
            if( isFirstValue ) {
                // starting over formatting sequence
                lastMonth = 50;
                lastYear = 5000;
    
                c.set(2018,0,1);
                c.add(Calendar.DATE, (int)chart.getLowestVisibleX());
    
                stickyMonth = c.get(Calendar.MONTH);
                stickyYear = c.get(Calendar.YEAR);
    
                String stickyText = monthFormatter.format(c.getTime()) + "\n" + stickyYear;
                sticky.setText(stickyText);
            }
    
            c.set(2018,0,1);
            c.add(Calendar.DATE, days);
            Date d = c.getTime();
    
            int dayOfMonth = c.get(Calendar.DAY_OF_MONTH);
            int month = c.get(Calendar.MONTH);
            int year = c.get(Calendar.YEAR);
    
            String monthStr = monthFormatter.format(d);
    
            if( (month > stickyMonth || year > stickyYear) && isFirstValue ) {
                stickyMonth = month;
                stickyYear = year;
                String stickyText = monthStr + "\n" + year;
                sticky.setText(stickyText);
            }
    
            String ret;
    
            if( (month > lastMonth || year > lastYear) && !isFirstValue ) {
                ret = monthStr;
            }
            else {
                ret = Integer.toString(dayOfMonth);
            }
    
            lastMonth = month;
            lastYear = year;
            lastFormattedValue = value;
    
            return ret;
        }
    }