As part of an Android app I’ve been working on lately I wanted to create an effect showing a number of photos, with random slight rotations and vertical positioning, sliding across the screen from right to left. The idea is that it would look like a panning scatter of photos, like camera panning across a coffee table scattered with prints. This turned out to be a pretty fun way to come back up to speed with Android and animation within the 2D canvas framework. Let’s see how it’s done!
Creating the Source Images
First I needed some images to use. Ideally these would come from the user’s gallery itself, but since this animation isn’t making it into the final version I haven’t written the code to do that. Instead I decided to manually embed the images into the resources. All the images are exported as JPEGs.
In Photoshop I created a batch action to add a cheap-looking white border to the images. I’ll be the first to admit that I’m not a great graphic designer, so I’ll leave styling the images as an exercise for the reader. You almost certainly can do a better job than I can. After processing them in Photoshop, adding them to the drawable-hdpi
directory allows them to show up in Eclipse. When we build our app they will now be embedded as resources for us to use.
Loading and Extending
Next we must extend the View class and start doing some real work. It’s a really common design pattern in Android to do this when creating UI elements. By making all of your UI classes inherent the members of View, you gain all the UI framework goodness of Android. Once we extend the class we chose a few key methods to override and suddenly we have the power to draw a custom UI control.
Class Declaration
public class PhotoStreamView extends View { public PhotoStreamView(Context context) { super(context); init(); } public PhotoStreamView(Context context, AttributeSet attrs) { super(context, attrs); init(); } }
Here we’ve referenced a function called init()
in each of the constructors. I’d recommend implementing at least these two constructors. When I left out the one taking an optional AttributeSet
I had some inflation issues when creating an instance of the custom view in a layout XML file. The init()
function is defined below, and works to setup all of the private member variables we’ll be using:
Initializing Bitmaps and Data Structures
// Static value holds the number of // images we want to load. private final int numItems = 13; // Member variables to hold all the bitmaps // and rotational values. We only instantiate // each item once, to prevent garbage collection // overhead. private Bitmap[] images; private Matrix[] coords; private float[] rotations; // A good ol' random number generator private Random r; private void init() { images = new Bitmap[numItems]; coords = new Matrix[numItems]; tmp = new float[9]; rotations = new float[numItems]; r = new Random(); // Load the images we'll be using from the resources. for(int i = 0; i < numItems; i++) { // We've named them main_01.. main_n String intStr = "main_" + String.format("%02d", i+1); int drawableId = getResources().getIdentifier(intStr, "drawable", "fusao.tangibleprints"); images[i] = BitmapFactory.decodeResource(getResources(), drawableId); // And setup a new translation matrix + rotation value coords[i] = new Matrix(); rotations[i] = 0; } Log.v("PhotoStreamView", "I'm alive!"); }
The getResources().getIdentifier()
method is really helpful for pragmatically retrieving items stored in the application resources. Be sure to update the namespace to match your app. In our case, my app is created in the fusao.tangibleprints
namespace, yours will be somewhere else!
Positioning of Images
Next thing is next. We want to position these darn things! Unfortunately this requires a little bit of math. There’s really two pieces to the puzzle. We’ll be using transformational matrices to rotate and translate the image, and a little bit of basic geometry to calculate the image bounds, and position relative to the canvas. In the code I allow the images to rotate 45 degrees to the left or right, giving them a natural scattered appearance.
Transformational Matrices
First off, I suggest taking a look at this wonderful guide on transformational matrices, it offers some really nice explanations.
Bitmaps in Android are drawn on the canvas using the drawBitmap function:
public void drawBitmap (Bitmap bitmap, Matrix matrix, Paint paint)
Which, from the documentation, takes the following parameters:
- bitmap – A reference to the bitmap to draw, in our case this is held in the
images[]
array we initialized above. It’s important not to create the bitmap objects every time we redraw the canvas, so we initialize them once at construction. - matrix – A transformational matrix, which allows for arbitrary scaling, translations, and rotations. This is what we’ll be focusing on! We also store these in a private member variable,
coords[]
. - paint – Optional paint information for the bitmap. We’ll be setting this to
null
, we’re not using it in this example.
So, the tough part is setting up a matrix correctly. The Android API provides a class, Matrix()
that makes things pretty easy on us. We’ll be using the transform and scale options within it to do this.
We initialize the matrices to empty states when the object is created, we don’t know anything about the size of the view yet. To actually set up some meaningful values we override the onSizeChanged()
event, which is called when the view is first fit into a larger container, and on every resize of the view (during rotations for example).
Determining Image Width
Also we must know the width of the image. Bitmap objects support a getWidth()
function, which does exactly this. Unfortunately once we begin to rotate the image, the width of a bounding box around the image actually increases past this value, up to a value that is equal to the diagonal distance of the image at a 90 degree rotation of the image. To find out this width, a little bit of geometry was needed. Since I’m working with a coordinate system with the center of the image as origin, I actually care about the length of the vector projected orthogonally from the hyperplane parallel to the edge of the screen touching the farthest point, to the origin of the image. To find this distance is pretty trivial.
The implementation is a little uglier, we convert to radians inline and Java has never had attractive-looking mathematical operators.
Tying it Together
Finally, here’s the code that ties both these ideas together. Using the image width and the width of the view to calculate a random position that puts the images initially off the screen to the right, and setting up the Matrix()
class to perform the translation and rotation.
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { // This event will be called when the view is first instantiated, because // we don't know the size of the view when it's constructed this provides // a nice place to setup things that require knowledge of it's size. for(int i = 0; i < numItems; i++) { // The rotation value is actually stored in the matrix, but this is // a little more clear/easier to work with, and only adds a few bytes of // static overhead. rotations[i] = (r.nextFloat() * 180) - 45; // We find the length of the diagnol component of the image // first, and then multiple // that by the cosine of the rotation angle to find the new // width from origin of the image. // This is important to detect when the image has moved off // screen, and to ensure it's placed // to the right of the screen to start with. float imageWidth = (float) (Math.sqrt(Math.pow((images[i].getHeight() / 2),2) + Math.pow((images[i].getWidth()/2)^2, 2)) * 2 * Math.abs(Math.cos(rotations[i]/180*Math.PI))); // Setup our image off screen, rotated by the aribrary random value we chose above. coords[i].setTranslate((float) (r.nextFloat()*w + w + imageWidth ), r.nextFloat()*h - images[i].getHeight()/2); coords[i].postRotate(rotations[i], images[i].getWidth()/2, images[i].getWidth()/2); } }
Drawing and Refreshing
One of my favorite features of the 2D Android API is how easy it is to draw views to the screen. Overriding the onDraw()
event gives you a reference to a canvas, and it’s super easy to draw right to it. In our onDraw()
function we’ll do three things, draw the image, update the transformational matrix to shift the image left a tiny bit, and check if the image has moved completely offscreen, at which point we’ll move it back to the very right, off canvas, so it can flow back on screen and keep the animation continuous.
A lot of this code is reused from the onSizeChanged()
function, so I won’t explain it in detail. Of interest is how we determine if the image is offscreen. Reading values out of the transformational matrix is pretty trivial. It turns out the 3 element of the Matrix holds the horizontal offset, we simply pull the matrix into a temporary float array, and read this value. Using the image width we know if the image has fallen off the left side of the screen.
@Override protected void onDraw(Canvas canvas) { for(int i = 0; i < 13; i++) { // Draw the bitmap to the screen. canvas.drawBitmap(images[i], coords[i], null); // Update the image by translating it to the // left a tiny bit (1% of the view size) coords[i].postTranslate((float) (-canvas.getWidth() * 0.01), 0); // Detect if it's off screen. coords[i].getValues(tmp); float imageWidth = (float) (Math.sqrt( Math.pow((images[i].getHeight() / 2),2) + Math.pow((images[i].getWidth()/2)^2, 2)) * 2 * Math.abs(Math.cos(rotations[i]/180*Math.PI))); // If the image's translation position (relative to origin) is off // screen, reset it back to the right of the view. if(tmp[2] < -imageWidth) { rotations[i] = (r.nextFloat() * 180) - 45; coords[i].reset(); coords[i].setTranslate((float) (r.nextFloat()*canvas.getWidth() + canvas.getWidth() + imageWidth), r.nextFloat()*canvas.getHeight() - images[i].getHeight()/2); coords[i].postRotate(rotations[i], images[i].getWidth()/2, images[i].getWidth()/2); } } // Draw, baby, draw! invalidate(); }
So there you have it! I ended up not using this effect in the production version of the app, choosing to go a different direction instead with the UI design, but I thought it was nifty enough to publish. I hope you enjoy it!