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?
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)
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;
}
}