To Scale or not to Scale

As this is yet another day I can't remember the various differences and meanings of the android:scaleType attribute values, I decided to have a closer look at the scaling mechanisms provided by ImageView.

The ImageView died that day

Android's ImageView class is used to draw arbitrary Drawable objects [0]. For the purpose of better understanding its internals, let us assume this class was not part of the Android framework and we would have to introduce a similar class for our project. How should the implementation look like?

The DrawableView

Let's name our class DrawableView as it is used solely to show object instances of type Drawable and extend it from View as this is the case with every other UI widget.
 

public class DrawableView extends View {

    public DrawableView(Context context) {
        super(context);
    }

    public DrawableView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public DrawableView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }
}

 
Now we have to add a custom drawable attribute to be used in the XML layout definition. So let's create a res/values/attrs.xml
 
<resources>
    <declare-styleable name="com.example.DrawableView">
        <attr name="src" format="reference"/>
    </declare-styleable>
</resources>
 
And add code to our DrawableView to retrieve the Drawable instance that will later on be referenced via the newly created src XML attribute
 

// ...


private Drawable drawable;

public DrawableView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);

        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.com_example_DrawableView, defStyle, 0);
        drawable = typedArray.getDrawable(R.styleable.com_example_DrawableView_src);

        typedArray.recycle();
}

// ...


 
Reading the src XML attribute in combination with the standard Android XML attributes already allows us to specify a header image in our layout view.
 
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:example="http://schemas.android.com/apk/res/com.example"

    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent">

    <com.example.DrawableView
        android:id="@+id/header"
        android:layout_height="wrap_content"
        android:layout_width="fill_parent"
        example:src="@drawable/header"/>

</LinearLayout>
 
As you can see in the code snippet above, android:layout_width and android:layout_height are used to specify the View's size. In my case the header image is 800 x 200 pixels, now the question raises what to do if a) the view is larger than the referenced image or b) the view is smaller than the referenced image. Let's start with the first case - the view is larger than the image.

Case 1 - View > Image

If the available view space is larger than the image, we want a way to specify that the image should be centered horizontally and vertically within the available space - this is exactly the case with ImageView.ScaleType.CENTER. The image is centered without scaling. To make our DrawableView work we will have to implement onMeasure first.
 

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int width  = drawable.getIntrinsicWidth();
        int height = drawable.getIntrinsicHeight();

        width  = Math.max(width, getSuggestedMinimumWidth());
        height = Math.max(height, getSuggestedMinimumHeight());

        setMeasuredDimension(resolveSize(width, widthMeasureSpec), resolveSize(height, heightMeasureSpec));
    }

 
First of all we'll have to read the drawable's width and height. If a minimum size is specified we'll have to take that into account using the Math.max static method. The most important part is the final line: here we used the View.resolveSize method to set the measured width and height accordingly. resolveSize acts upon the parent views measure specification. In our case we are using a linear layout that fills its parent view and we have specified android:layout_width="fill_parent" and android:layout_height="wrap_content". Given we use a very small header file for testing purposes (e.g. with 200 x 50 pixels), resolveSize would return the width of the actual screen (e.g. 320) but the height of the drawable we suggested. If we would have declared the height to fill the parent view, resolveSize would return the total height otherwise. Now let us implement the drawing functionality.
 

    private Drawable drawable;
    private Matrix matrix = new Matrix();

    private int drawableWidth, drawableHeight;
    private int viewWidth, viewHeight;

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        drawableWidth  = drawable.getIntrinsicWidth();
        drawableHeight = drawable.getIntrinsicHeight();

        int width  = Math.max(drawableWidth, getSuggestedMinimumWidth());
        int height = Math.max(drawableHeight, getSuggestedMinimumHeight());

        viewWidth  = resolveSize(width, widthMeasureSpec);
        viewHeight = resolveSize(height, heightMeasureSpec);

        setMeasuredDimension(viewWidth, viewHeight);
    }


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

        int saveCount = canvas.save();

        // this will center the small header image

        matrix.setTranslate((int) ((viewWidth - drawableWidth) * 0.5f + 0.5f), (int) ((viewHeight - drawableHeight) * 0.5f + 0.5f));

        canvas.concat(matrix);

        drawable.setBounds(0, 0, drawableWidth, drawableHeight);
        drawable.draw(canvas);

        canvas.restoreToCount(saveCount);
    }

 
I had to refactor the previous code in onMeasure, to store the measured width and height in instance variables. Just drawing the given drawable on the canvas is easy and is done by a call to drawable.draw(canvas). But we want our drawable to be centered in the middle of our DrawableView bounds. This is done by applying a matrix which translates every single pixel of the drawable by a given amount of pixels. Translation is nothing but a simple calculation whereas each point in matrix M gets translated via M' = M * T(dx, dy). With the code above we have already implemented exactly the same functionality as if we applied android:scaleType="center" to an Android ImageView. Cool, isn't it? Now let's consider a case that includes image scaling.

Case 2 - View < Drawable

Let us assume we replace the small header image with a large one, say 800 x 200 px. With a screen height of 480 px the height of the image would fit, but the width of the image is too large. What to do in that case? One approach would be to still keep the image ratio, but scale the image so that at least one axis fits for the view and center the result - which leads to cropping the left/right and/or upper/lower part of the image. That means for our 800 x 200 px image, that the full height is drawn (assuming the screen height has 480 px), but the image width is cropped by 480 pixels, 240 left and 240 right.
 
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int saveCount = canvas.save();

        float scale;
        float dx = 0, dy = 0;

        if (drawableWidth * viewHeight > viewWidth * drawableHeight) {
            scale = (float) viewHeight / (float) drawableHeight;
            dx = (viewWidth - drawableWidth * scale) * 0.5f;
        } else {
            scale = (float) viewWidth / (float) drawableWidth;
            dy = (viewHeight - drawableHeight * scale) * 0.5f;
        }

        matrix.setScale(scale, scale);
        matrix.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f));

        canvas.concat(matrix);

        drawable.setBounds(0, 0, drawableWidth, drawableHeight);
        drawable.draw(canvas);

        canvas.restoreToCount(saveCount);
    }

 
The scaling mechanism seen in the onDraw method above is used to scale the image accordingly to the view's bounds. In our case, we specified fill_parent and wrap_content, but that could have been also absolute measures or other layout measures. Ah, and the code above is exactly what android:scaleType="centerCrop" causes. But cropping isn't that beautiful, is it? Let's implement the onDraw method to scale the image so that it fits within the given view bounds, still keeping the image ratio.
 
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        int saveCount = canvas.save();

        float scale;
        float dx;
        float dy;

        if (drawableWidth <= viewWidth && drawableHeight <= viewHeight) {
            scale = 1.0f;
        } else {
            scale = Math.min((float) viewWidth / (float) drawableWidth,
                    (float) viewHeight / (float) drawableHeight);
        }

        dx = (int) ((viewWidth - drawableWidth * scale) * 0.5f + 0.5f);
        dy = (int) ((viewHeight - drawableHeight * scale) * 0.5f + 0.5f);

        matrix.setScale(scale, scale);
        matrix.postTranslate(dx, dy);

        canvas.concat(matrix);

        drawable.setBounds(0, 0, drawableWidth, drawableHeight);
        drawable.draw(canvas);

        canvas.restoreToCount(saveCount);
    }
 
As you can see in the screenshot below, the actual View bounds are now larger than the drawable height what we returned in onMeasure. Android's ImageView behaves exactly the same, but it provides an attribute to adjust the view bounds to the image bounds: android:adjustViewBounds. To apply the view bounds to the scaled drawable dimensions, we would need to write
 
<ImageView
        android:id="@+id/header"
        
        android:layout_height="wrap_content"
        android:layout_width="fill_parent"
        android:scaleType="centerInside"

        android:adjustViewBounds="true"

        android:src="@drawable/header"/>
 
Which results in

What about the other ScaleType configuration values?

What we did not see so far is the implementation of ImageView.ScaleType.FIT_* attributes and ImageView.ScaleType.MATRIX. The ImageView.ScaleType.MATRIX attribute can be set to use a custom matrix when drawing the image. With ImageView.setImageMatrix arbitrary operations can be executed during drawing the drawable. All FIT_* scale types actually use the Matrix.setRectToRect method, which allows to translate between two given rectangular bounds.
 

Rect src = new Rect(0, 0, drawableWidth, drawableHeight);
Rect dst = new Rect(0, 0, viewWidth, viewHeight);
                
matrix.setRectToRect(src, dst, scaleType);

 
The interesting part in the example above is the scaleType. This can be one of Matrix.ScaleToFit enumeration values: CENTER, START, END and FILL which exactly map to ImageView.ScaleType's FIT_CENTER, FIT_START, FIT_END and FIT_XY. This means that all fit operations internally use a drawing matrix that scale from the drawl bounds to the actual view bounds.

Conclusion

This article tried to bring light into Android's ImageView various scaling options. In fact, with some basic knowledge about the View, Canvas and Matrix classes and some Matrix operations we were able to implement our DrawableViewclone. The code for this blog post can be found at Github: https://github.com/andresteingress/android-drawable-view [1]

[0] The ImageView Documentation - http://developer.android.com/reference/android/widget/ImageView.html
[1] android-drawable-view Example Project @ Github - https://github.com/andresteingress/android-drawable-view