Punching Holes in Android Views

24 April 2019

24 Apr 2019

24/4/19

Simon Bradley

3 MIN READ

3 MIN READ

In this tutorial, I’m going to show you how you can “punch through” a part of an Android view in order to expose what’s behind it. While this might not be a very common UI paradigm, I’ve personally used it in a production app — and it looked pretty neat! So that you know where we’re heading, here’s an example of this in action.

To achieve this effect, we’re going to be creating a custom view class. I’ve subclassed ConstraintLayout (because it’s my preferred way to create Android layouts), but you can use a different superclass, if you prefer. The only restriction is that you need to use a ViewGroup, because we’re going to use a child view to define our “window”.

Here’s the relevant XML excerpt:

<au.com.adapptor.view.WindowView
    android:layout_width="300dp"
    android:layout_height="300dp"
    android:background="#80ffffff"
    app:windowView_view="@id/window"
    app:windowView_drawable="@drawable/circle"
    >

    <ImageView
        android:id="@+id/window"
        android:layout_width="150dp"
        android:layout_height="150dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        android:padding="20dp"
        android:src="@drawable/spaceman"
        android:scaleType="fitCenter"
        />

</au.com.adapptor.view.WindowView>

I’ve used an ImageView to make my example more fun, but it’s the view’s bounds that locate the window, so any view will do.

One thing to note in this layout is the two custom attributes, windowView_viewand windowView_drawable. These are described later in this tutorial, but here’s where they’re defined, in a <resources> XML element.

<declare-styleable name="WindowView">
    <attr name="windowView_view" format="reference" />
    <attr name="windowView_drawable" format="reference" />
</declare-styleable>

Now let’s take a look at the implementation of WindowView. As I said above, my class extends ConstraintLayout. Here are the member variables we’re going to need:

  • int mWindowViewId — The ID of the view that defines the window’s bounds.

  • View mWindowView — The window’s view.

  • Drawable mWindowDrawable — A drawable that defines the window’s shape. This is a kind of mask: non-transparent pixels will define the window. I used a circle (which I defined in an XML drawable) in my example video, but it can be any shape you like.

  • Bitmap mWindowBitmap — The window’s drawable after being converted to a bitmap (if necessary).

  • int[2] mViewCoords — The coordinates of this view in its window. Stored as a member variable to avoid repeated allocations inside onDraw.

  • int[2] mWindowCoords — Similar to the above, but the coordinates of our window.

  • Paint mPaint — A Paint used to draw the window. Again, stored as a member variable to avoid repeated allocations.

First, we need to do a little initialisation. Calling setLayerType is required to avoid some strange rendering issues that can occur using a software layer. Note the call to setXfermode. This is critical, as it is the use of the Porter/Duff DstOut blend mode that erases the background. The rest of the code is extracting the view and drawable attributes that we set in the layout XML. (Note: error checking omitted for brevity.) Here’s our constructor.

public WindowView(Context context, AttributeSet attrs) {
    super(context, attrs);

    setLayerType(LAYER_TYPE_HARDWARE, null);
    mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OUT));

    final TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.WindowView, 0, 0);
    mWindowViewId = ta.getResourceId(R.styleable.WindowView_windowView_view, 0);
    int drawableId = ta.getResourceId(R.styleable.WindowView_windowView_drawable, 0);
    mWindowDrawable = ContextCompat.getDrawable(context, drawableId);
    ta.recycle();
}

Now we can get on with the actual work of drawing our view. To get the effect we’re looking for, we override onDraw.

@Override
public void onDraw(Canvas canvas) {
    if (mWindowView == null) {
        mWindowView = findViewById(mWindowViewId);
    }

    if (mWindowView != null && mWindowDrawable != null && mWindowView.getVisibility() == VISIBLE) {
        mWindowBitmap = checkBitmap(mWindowView, mWindowDrawable, mWindowBitmap);

        getLocationInWindow(mViewCoords);
        mWindowView.getLocationInWindow(mWindowCoords);

        float x = mWindowCoords[0] - mViewCoords[0];
        float y = mWindowCoords[1] - mViewCoords[1];

        canvas.drawBitmap(mWindowBitmap, x, y, mPaint);
    }
}

The first block checks whether or not we’ve already got a reference to the window’s view and, if not, looks it up.

The second block does some basic checks, then performs the following steps:

  1. Creates the window’s bitmap, if necessary;

  2. Gets the locations of the view and the window;

  3. Calculates the coordinates at which the window should be drawn;

  4. Draws the window’s bitmap at the calculated coordinates.

There are a couple of supporting methods being used in onDraw, which I’ll include here. First, here’s checkBitmap:

/**
 * If {@code bitmap} is not null and has the same dimensions as {@code view}, returns {@code bitmap};
 * otherwise, a new {@code Bitmap} is generated from {@code drawable} using {@code view}'s dimensions.
 * @param view the {@code View} used to check the dimensions of the {@code Bitmap}
 * @param drawable the {@code Drawable} from which the new {@code Bitmap} is created
 * @param bitmap the {@code Bitmap} to check
 * @return the {@code Bitmap} that should be rendered, whether existing or new
 */
private static Bitmap checkBitmap(View view, Drawable drawable, Bitmap bitmap) {
    int width = view.getWidth() - (view.getPaddingLeft() + view.getPaddingRight());
    int height = view.getHeight() - (view.getPaddingTop() + view.getPaddingBottom());

    return bitmap == null || bitmap.getWidth() != width || bitmap.getHeight() != height
            ? bitmapFromDrawable(drawable, view.getWidth(), view.getHeight())
            : bitmap;
}

And here is bitmapFromDrawable:

/**
 * Converts a {@code Drawable} to a {@code Bitmap}. If {@code drawable} is an instance of {@code BitmapDrawable}
 * and it is of the requested size, then {@code drawable}'s bitmap is returned.
 * @param drawable the {@code Drawable} to convert
 * @param width the width of the resulting bitmap
 * @param height the height of the resulting bitmap
 * @return the new bitmap
 */
public static Bitmap bitmapFromDrawable(Drawable drawable, int width, int height) {
    if (drawable instanceof BitmapDrawable) {
        final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();

        if (width == bitmap.getWidth() && height == bitmap.getHeight()) {
            return bitmap;
        }
    }

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

    drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
    drawable.draw(canvas);

    return bitmap;
}

Of course, there are many ways in which you can customise or extend this solution. One obvious addition would be the ability to add multiple windows. This could be achieved by passing and parsing strings for the two XML attributes, instead of single references. This I shall leave as an exercise for the reader!