Wednesday, October 23, 2013

DateUtils.getRelativeTimeSpanString() and Timezones

If you're thinking about DateUtils.getRelativeTimeSpanString(): don't.

After a long struggle I've finally given up on the method.  It seems so tempting - an easy way of showing the user how far away an event will be (or was).  But without being able to pass a timezone you run into an intractable situation which makes it unusable.

The critical problem relates to how it calculates a number of days.  It relies on Time.getJulianDay(), which would work - except you have no control over the timezone parameter.  Instead, it uses the default device timezone.  This can cause day calculations to be incorrect as you might shift a Julian day when applying the device timezone to the milliseconds provided from another timezone.

Before 4.3, the situation was even worse: the internal Time used in DateUtils (for calls to getJulianDay()) is cached.  That meant if your app uses getRelativeTimeSpanString(), then the device's timezone changes, you're now calculating the # of days based on the previous device timezone!

The only comprehensive solution I can come up with is to implement my own version of getRelativeTimeSpanString() that adds the timezone element.  You just need to account for timezones when you're dealing with any period of time greater than hours.

Thursday, October 3, 2013

Centering Single-Line Text in a Canvas

Suppose I want a custom View that draws a circle, then a number centered inside of it.  Your first attempt will probably look something like this:


The problem is that while you can easily set a horizontal alignment for your TextPaint (via Paint.Align), the vertical alignment is tricky.  That's because Canvas.drawText() starts drawing at the baseline of your set Y-coordinate, instead of the center.

If you only knew the height of the text, then you could center it yourself - but getting the height is tricky!  TextPaint.getTextBounds() doesn't work quite right because it gives you the minimal bounding rectangle, not the height that the TextPaint draws.  For example, if your text has no ascenders/descenders, then the measured height is smaller than it will draw (since it will still account for the possibility of them).

The way I've found to get the height of the TextPaint is to use ascent() and descent().  These measure the size above/below the text's baseline.  Combined, they add up to the total height of the drawn text.  You can then use some math to center the draw on the baseline - here's a version of onDraw() that does it correctly*:

protected void onDraw(Canvas canvas) {
  super.onDraw(canvas);

  Paint paint = new Paint();
  paint.setColor(Color.BLACK);

  TextPaint textPaint = new TextPaint();
  textPaint.setColor(Color.WHITE);
  textPaint.setTextAlign(Paint.Align.CENTER);
  float textHeight = textPaint.descent() - textPaint.ascent();
  float textOffset = (textHeight / 2) - textPaint.descent();

  RectF bounds = new RectF(0, 0, getWidth(), getHeight());
  canvas.drawOval(bounds, paint);
  canvas.drawText("42", bounds.centerX(), bounds.centerY() + textOffset, textPaint);
}

And the finished product is here:


Note that all advice in this post is about a single line of text.  If you're handling multi-line text then the solution is more complex because you have to handle how many lines the text will render onto.  If I ever try to tackle that I'll write that up as well.

* In a real-world example, you wouldn't want to instantiate your Paints in onDraw(); this is done for brevity's sake.