2
votes

I am working on creating a custom ImageView which will crop my image into a hexagon shape and add a border. I was wondering if my approach is correct or if I am doing this the wrong way. There are a bunch of custom libraries out there that already do this but none of them out of the box have the shape I am looking for. That being said, this is more a question about the best practice.

expected result

You can see the full class in this gist, but the main question is that is this the best approach. It feels wrong to me, partly because of some of the magic numbers which means it could be messed up on some devices.

Here is the meat of the code:

      @Override
      protected void onDraw(Canvas canvas) {
        Drawable drawable = getDrawable();
        if (drawable == null || getWidth() == 0 || getHeight() == 0) {
          return;
        }

        Bitmap b = ((BitmapDrawable) drawable).getBitmap();
        Bitmap bitmap = b.copy(Bitmap.Config.ARGB_8888, true);

        int dimensionPixelSize = getResources().getDimensionPixelSize(R.dimen.width); // (width and height of ImageView)
        Bitmap drawnBitmap = drawCanvas(bitmap, dimensionPixelSize);
        canvas.drawBitmap(drawnBitmap, 0, 0, null);
      }

      private Bitmap drawCanvas(Bitmap recycledBitmap, int width) {
        final Bitmap bitmap = verifyRecycledBitmap(recycledBitmap, width);

        final Bitmap output = Bitmap.createBitmap(width, width, Bitmap.Config.ARGB_8888);
        final Canvas canvas = new Canvas(output);

        final Rect rect = new Rect(0, 0, width, width);
        final int offset = (int) (width / (double) 2 * Math.tan(30 * Math.PI / (double) 180)); // (width / 2) * tan(30deg)
        final int length = width - (2 * offset);

        final Path path = new Path();
        path.moveTo(width / 2, 0); // top
        path.lineTo(0, offset); // left top
        path.lineTo(0, offset + length); // left bottom
        path.lineTo(width / 2, width); // bottom
        path.lineTo(width, offset + length); // right bottom
        path.lineTo(width, offset); // right top
        path.close(); //back to top

        Paint paint = new Paint();
        paint.setStrokeWidth(4);
        canvas.drawARGB(0, 0, 0, 0);
        canvas.drawPath(path, paint);
        paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
        canvas.drawBitmap(bitmap, rect, rect, paint); // draws the bitmap for the image

        paint.setColor(Color.parseColor("white"));
        paint.setStrokeWidth(4);
        paint.setDither(true);
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeJoin(Paint.Join.ROUND);
        paint.setStrokeCap(Paint.Cap.ROUND);
        paint.setPathEffect(new CornerPathEffect(10));
        paint.setAntiAlias(true); // draws the border

        canvas.drawPath(path, paint);

        return output;
      }

I was looking at some iOS code and they are able to apply an actual image as a mask to achieve this result. Is there anyway on Android to do something like that?

2
Maybe this fits your needs?Phantômaxx
I would say, make your stroke width, offset + X & CornerPathEffect convert to pixels from dp (using TypedValue), but other than that, it looks pretty good. 30 degrees is fine, since 30 degrees is 30 degrees on any device (or anywhere in the universe for that matter)Gil Moshayof
@GilMoshayof thanks, I guess I was looking at some iOS code and they are able to apply a svg as a layer over their images. I was hoping there was something I was missing in Android that would allow me to do this.trev9065

2 Answers

3
votes

I was looking for the best approach for a long time. Your solution is pretty heavy and doesn't work well with animations. The clipPath approach doesn't use antialiasing and doesn't work with hardware acceleration on certain versions of Android (4.0 and 4.1?). Seems like the best approach (animation friendly, antialiased, pretty clean and hardware accelerated) is to use Canvas layers:

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
private static PorterDuffXfermode pdMode = new PorterDuffXfermode(PorterDuff.Mode.CLEAR);

@Override
public void draw(Canvas canvas) {
        int saveCount = canvas.saveLayer(0, 0, getWidth(), getHeight(),
                                         null, Canvas.ALL_SAVE_FLAG);

        super.draw(canvas);

        paint.setXfermode(pdMode);
        canvas.drawBitmap(maskBitmap, 0, 0, paint);

        canvas.restoreToCount(saveCount);
        paint.setXfermode(null);
}

You can use any kind of mask including custom shapes and bitmaps. Carbon uses such approach to round corners of widgets on the fly.

1
votes

Even though it might work, there a few bad mistakes on this implementation:

  • You're allocation some very big objects during onDraw phase and that leads to a terrible performance. The most important there is the createBitmap but you should avoid at all costs any new during onDraw. Pre-allocate all necessary objects during initialisation and re-use them during onDraw.

  • You should setup your path just once during onSizeChanged. Avoid all that path on every OnDraw

  • You're relying on usage of BitmapDrawable if for example you use Picasso to load images from the internet or if you want to use a selector, then this code won't work.

  • You should not need to allocate the second bitmap, use canvas.clipPath instead to make it efficient.

Said all that a much more efficient pseudo code for the drawing should be:

@Override
protected void onDraw(Canvas canvas) {
   canvas.save(CLIP_SAVE_FLAG); // save the clipping
   canvas.clipPath(path, Region.Op./*have to test which one*/ ); // cut the canvas
   super.onDraw(canvas); // do the normal drawing
   canvas.restore(); // restore the saved clipping
   canvas.drawPath(path, paint); // draw the extra border
}