While developing my first couple Android apps (which didn’t make it to market), and my first one that actually did make it to market (shameless plug: Should I Wear Pants?), I noticed what is, in my opinion, a significant issue: No easy way to look at least halfway decent on all devices. We’re supporting a massive variety of different screen sizes and densities, and the best the Android SDK can provide us natively are:

What we were able to find was a PDF white paper from Vanteon Electronic Design about this very subject – auto-scaling.  They explain in further detail the two problems I outlined above, and start going into detail on a solution.  The advantages of auto-scaling include:

  • Considerably easier support for many devices.  You probably still need to make separate layouts for tablets and phones – it depends what makes sense for your app – but that’s really it.  For each form factor, you don’t have much to worry about as far as screen size/density.
  • In most cases, you only need to include one drawable – the highest resolution version – because the image will be scaled to fit.  The only exception to this is when you’re concerned about the amount of memory your activity consumes – I’m not sure how Android handles memory with scaled images, if it’ll store the full-size version in memory or just what’s displayed.

We took the solution one step further – rather than having the Activity run the scaling code, we used it and created a ScalingLinearLayout view that automatically scales itself and its contents when it’s drawn or redrawn.  We used, and tweaked slightly, the code from that PDF, in a file called Scale.java (to copy and paste this, use the “View Raw Code” button):

package com.icantbelieveitsnotcloud.lib.util;

import android.util.Log;
import android.view.View;
import android.view.ViewGroup;

public class Scale {
	public static void scaleContents(View rootView, View container) {
		Scale.scaleContents(rootView, container, rootView.getWidth(), rootView.getHeight());
	}

	// Scales the contents of the given view so that it completely fills the given
	// container on one axis (that is, we're scaling isotropically).
	public static void scaleContents(View rootView, View container, int width, int height) {
		Log.d("notcloud.scale", "Scale::scaleContents: container: " + container.getWidth() + "x" + container.getHeight() + ".");

		// Compute the scaling ratio
		float xScale = (float)container.getWidth() / width;
		float yScale = (float)container.getHeight() / height;
		float scale = Math.min(xScale, yScale);

		// Scale our contents
		Log.d("notcloud.scale", "Scale::scaleContents: scale=" + scale + ", width=" + width + ", height=" + height + ".");
		scaleViewAndChildren(rootView, scale, 0);
	}

	// Scale the given view, its contents, and all of its children by the given factor.
	public static void scaleViewAndChildren(View root, float scale, int canary) {
		// Retrieve the view's layout information
		ViewGroup.LayoutParams layoutParams = root.getLayoutParams();

		// Scale the View itself
		if(layoutParams.width != ViewGroup.LayoutParams.MATCH_PARENT && layoutParams.width != ViewGroup.LayoutParams.WRAP_CONTENT) {
			layoutParams.width *= scale;
		}
		if(layoutParams.height != ViewGroup.LayoutParams.MATCH_PARENT && layoutParams.height != ViewGroup.LayoutParams.WRAP_CONTENT) {
			layoutParams.height *= scale;
		}

		// If the View has margins, scale those too
		if(layoutParams instanceof ViewGroup.MarginLayoutParams) {
			ViewGroup.MarginLayoutParams marginParams = (ViewGroup.MarginLayoutParams)layoutParams;
			marginParams.leftMargin *= scale;
			marginParams.topMargin *= scale;
			marginParams.rightMargin *= scale;
			marginParams.bottomMargin *= scale;
		}
		root.setLayoutParams(layoutParams);

		// Same treatment for padding
		root.setPadding(
			(int)(root.getPaddingLeft() * scale),
			(int)(root.getPaddingTop() * scale),
			(int)(root.getPaddingRight() * scale),
			(int)(root.getPaddingBottom() * scale)
		);

		// If it's a TextView, scale the font size
		/*
		if(root instanceof TextView) {
			TextView tv = (TextView)root;
			tv.setTextSize(tv.getTextSize() * scale); //< We do NOT want to do this.
		}
		*/

		// If it's a ViewGroup, recurse!
		if(root instanceof ViewGroup) {
			ViewGroup vg = (ViewGroup)root;
			for(int i = 0; i < vg.getChildCount(); i++) {
				scaleViewAndChildren(vg.getChildAt(i), scale, canary + 1);
			}
		}
	}
}

You’ll notice that we have the TextView font scaling parts shut off.  That’s code that came in from Vanteon’s scaling, but in the apps we’ve used it in, we’ve found it undesirable.  I left it in the article because you may want to use it – if so, find the comment that says “If it’s a TextView, scale the font size” and uncomment the if() {} block that follows.

Using that Scale class, we built ScalingLinearLayout, which extends LinearLayout, as follows:

package com.icantbelieveitsnotcloud.lib.view;

import android.content.Context;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.widget.LinearLayout;
import com.icantbelieveitsnotcloud.lib.util.Scale;

public class ScalingLinearLayout extends LinearLayout {
	int baseWidth;
	int baseHeight;
	boolean alreadyScaled;
	float scale;
	int expectedWidth;
	int expectedHeight;

	public ScalingLinearLayout(Context context) {
		super(context);

		Log.d("notcloud.view", "ScalingLinearLayout: width=" + this.getWidth() + ", height=" + this.getHeight());
		this.alreadyScaled = false;
	}

	public ScalingLinearLayout(Context context, AttributeSet attributes) {
		super(context, attributes);

		Log.d("notcloud.view", "ScalingLinearLayout: width=" + this.getWidth() + ", height=" + this.getHeight());
		this.alreadyScaled = false;
	}

	public void onFinishInflate() {
		Log.d("notcloud.view", "ScalingLinearLayout::onFinishInflate: 1 width=" + this.getWidth() + ", height=" + this.getHeight());

		// Do an initial measurement of this layout with no major restrictions on size.
		// This will allow us to figure out what the original desired width and height are.
		this.measure(1000, 1000); // Adjust this up if necessary.
		this.baseWidth = this.getMeasuredWidth();
		this.baseHeight = this.getMeasuredHeight();
		Log.d("notcloud.view", "ScalingLinearLayout::onFinishInflate: 2 width=" + this.getWidth() + ", height=" + this.getHeight());

		Log.d("notcloud.view", "ScalingLinearLayout::onFinishInflate: alreadyScaled=" + this.alreadyScaled);
		Log.d("notcloud.view", "ScalingLinearLayout::onFinishInflate: scale=" + this.scale);
		if(this.alreadyScaled) {
			Scale.scaleViewAndChildren((LinearLayout)this, this.scale, 0);
		}
	}

	public void draw(Canvas canvas) {
		// Get the current width and height.
		int width = this.getWidth();
		int height = this.getHeight();

		// Figure out if we need to scale the layout.
		// We may need to scale if:
		//    1. We haven't scaled it before.
		//    2. The width has changed.
		//    3. The height has changed.
		if(!this.alreadyScaled || width != this.expectedWidth || height != this.expectedHeight) {
			// Figure out the x-scaling.
			float xScale = (float)width / this.baseWidth;
			if(this.alreadyScaled && width != this.expectedWidth) {
				xScale = (float)width / this.expectedWidth;
			}
			// Figure out the y-scaling.
			float yScale = (float)height / this.baseHeight;
			if(this.alreadyScaled && height != this.expectedHeight) {
				yScale = (float)height / this.expectedHeight;
			}

			// Scale the layout.
			this.scale = Math.min(xScale, yScale);
			Log.d("notcloud.view", "ScalingLinearLayout::onLayout: Scaling!");
			Scale.scaleViewAndChildren((LinearLayout)this, this.scale, 0);

			// Mark that we've already scaled this layout, and what
			// the width and height were when we did so.
			this.alreadyScaled = true;
			this.expectedWidth = width;
			this.expectedHeight = height;

			// Finally, return.
			return;
		}

		super.draw(canvas);
	}
}

When calling this in a layout, you’ll want to do something along these lines:

<com.icantbelieveitsnotcloud.lib.view.ScalingLinearLayout
	android:id="@+id/sllInventory"
	android:layout_width="match_parent"
	android:layout_height="match_parent"
	android:background="@drawable/dark_rock_tile"
	android:gravity="center">
		<RelativeLayout android:id="@+id/rlyInventory"
			android:layout_width="120px"
			android:layout_height="165px">
		</RelativeLayout>
</com.icantbelieveitsnotcloud.lib.view.ScalingLinearLayout>

And that’s it. The resulting effect is that it will scale to fill either the width or the height, whichever ends up being a smaller scale – this way it doesn’t overflow the other dimension. It operates by exploiting the fact that, before being drawn, the ScalingLinearLayout‘s size will only be enough to contain the stuff inside it. During the draw() function, though, the match_parent width and height directives will cause the full size to be returned by getWidth() and getHeight(). It then performs some simple math on the new and original dimensions, and calls the scaleViewAndChildren() method from our Scale class above.

There’s no need for any additional XML files for styleable attributes, because ScalingLinearLayout accepts exactly the same attributes as LinearLayout. You could easily adapt this to make (for example) a ScalingRelativeLayout that extends RelativeLayout, if you had such a need. If we do this for anything in the future, I’ll be sure to post it on here – but given that we’re just wrapping the ScalingLinearLayout around a RelativeLayout in this case, I don’t anticipate such a need.

Since this is published on the Internet, feel free to use it in your work – no copyright. Any questions, feel free to leave them in a comment. I’m happy to answer them.