Wednesday, October 19, 2011

Creating a custom view, part 1 - Graphics

It's not often I write about user interface and applications, but since I have now received a few question on the subject, I thought I would describe one rather common task - how to create a custom view.

An activity containing a horizontal and vertical slider.
You will make your own customised slider, with the following features:
  • Scale and stretch it depending on screen size, UI design and orientation.
  • Change position from the activity.
  • React to touches.
  • Set min and max values from xml or application code.
There are many other features that could be added, but I will only add them if requested enough times in the comments.

The howto is divided into a few distinct steps, each describing a feature of the customised view.
Depending on the purpose of your own custom view, not all of the steps may be needed. Furthermore I have divided the howto into three parts, to make it a bit less overwhelming:



The source code can be downloaded from github, and all steps are tagged in the git database. Each step will be preceded with the git command you need in order to look at the code step by step. If you prefer, you can just look at the latest commit in git, since that includes the complete example.


Step 0. Download the source.

This is really optional, but it's probably a good idea to have the code nearby so you can compare my code with yours.
All these git commands are written for Linux command line. It should be very easy to from MS Windows or Mac OS as well, but unfortunately I have no experience in using git on any of those so you will have to find out yourself.

Create a working directory and cd to it. I have called mine "enea":
mkdir enea
cd enea
Use git clone to get the project, and cd to it:
git clone git://github.com/androidenea/CustomView.git
cd CustomView
You may now import the CustomView project to Eclipse.

All further steps in this howto have been clearly tagged for easy access. Each step starts with one line showing the git command needed to get to the correct place.

Please note that checking out a tag in git will get you to the correct version of the source code, but you are not allowed to commit any changes (unless you know what you are doing.)
If you want to modify your code and store that in git, you will need to create your own branch first. This is not the time and place for a tutorial about git, but I highly recommend the book "Pro Git" if you want to know more.

Step 1. Create a project


git checkout step_1

The first step will just give you a playground and a test harness in the shape of an activity.
Grab the code from the git repository for an easy starting point, or do it yourself in Eclipse:
Open Eclipse and create an Android project and fill in the following parametres:
  • Project name: CustomView
  • Build target: 1.6 or newer
  • Application name: CustomView
  • Package name: com.enea.training.customview
  • Create Activity: CustomViewActivity
  • Min SDK version: 4

Click finish to create your project. If you don't really understand what all those things actually mean, you will have a hard time understanding the other steps, as this tutorial is rather advanced. Read through a few other beginner's tutorials first and then you are most welcome back here.

To get a good base layout for the tests, and to get an idea of what this whole tutorial is about, you should replace the LinearLayout in main.xml with a RelativeLayout containing a SeekBar. The contents of main.xml should look like this:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    >
    <SeekBar android:id="@+id/slider"
      android:layout_height="wrap_content"
      android:layout_width="fill_parent"
      android:layout_alignParentLeft="true"
      android:layout_alignParentTop="true"
/>
</RelativeLayout>
That's it for step 1. That wasn't too bad was it?
Step 1. Standard SeekBar.

Step 2. Add a custom view.


This is by far the biggest step in this howto. We will add bitmaps and source code and we will also replace the SeekBar in step 1 with the custom view. We will do this in small steps to keep things apart.

Step 2.1 Bitmaps


git checkout step_2_1

There are three bitmaps that will be needed. I suggest you grab the ones from the git repository, even if you are implementing the rest without looking at my sample. The reason is that designing stretchable bitmaps is not within the scope of this tutorial. There are very good guidelines on how to design stretchable, or NinePatch, bitmaps on the Android SDK web page, here: Draw 9-patch

Copy the whole of directory res/drawable to your project. I have not spent much time on the graphics, other than making sure to use the nice Enea-red colour, but the bitmaps do scale well and will not look too shabby on anything from my small 2.6" QVGA phone to my nice 10.1" WXGA tablet.

Step 2.2 Slider class


git checkout step_2_2

Ok, so let's try to make use of those bitmaps, shall we?
Create a new class in the same package as earlier, and let it extend android.widget.View. In Eclipse, right-click on the package and select New->Class.
Fill in the blanks as follows:
  • Name: CustomSlider
  • Superclass: android.view.View
  • Generate constructors from superclass
Everything else should be left as default. Click Finish to create the class.

The two simpler constructors can just call to the most flexible one, with 0 or null as parameters, like so (the line numbers should roughly match the source code in git, assuming that you have checked out the corresponding step):
public CustomSlider(final Context context) {
  this(context, null, 0);
}

public CustomSlider(final Context context, final AttributeSet attrs) {
  this(context, attrs, 0);
}

The last constructor will need to do a few things though. It will be extended as we go along, but for now all that is needed is to point out the bitmaps to be used. For that you will also need to add references to the bitmaps.
Add the following members to your class:
private final Drawable mIndicator;
private final Drawable mBackground;

To initialise them, you will need a handle to the resources, and then set them, using the resource reference id.
Your third constructor should look like this:
public CustomSlider(Context context, AttributeSet attrs, int defStyle) {
  super(context, attrs, defStyle);

  final Resources res = context.getResources();
  mIndicator = res.getDrawable(R.drawable.indicator_horizontal);
  mBackground = res.getDrawable(R.drawable.background_horizontal);
}

Let Eclipse add any missing imports that might be needed.

Step 2.3 onDraw


git checkout step_2_3

All the drawing is happening in onDraw(). This method is potentially called quite often, so make a good habit out of making it as fast as possible. In particular, avoid allocating and freeing memory in it.
In this example, onDraw() will calculate the position for the indicator and draw the background, followed by the indicator, positioned relative to the background's end points.
For this to work there are a few member fields needed. First of all the normalised position of the indicator:
private float mMin;
  private float mMax;
  private float mPosition;
These need to be initialised in the constructor. For now just assume that the slider goes from -1.0 to 1.0 and that the indicator is positioned in the middle.
Add the following lines to the bottom of the constructor:
mMin = -1.0f;
    mMax = 1.0f;
    mPosition = (mMax - mMin) / 2 + mMin;
Then a few member fields are needed to help out with the drawing. We need somewhere to store the view's absolute drawing area:
private Rect mViewRect;
The offset, min and max absolute positions for the indicator are also stored, so we don't need to recalculate them every time.
private int mIndicatorOffset;
  private int mIndicatorMaxPos;
  private int mIndicatorMinPos;
And finally there's the actual onDraw method:
@Override
  protected void onDraw(final Canvas canvas) {
First, check if the absolute drawing area is null, and if it is, fill in all the absolute values:
if (mViewRect == null) {
      mViewRect = new Rect();
      getDrawingRect(mViewRect);
      mIndicatorOffset = mIndicator.getIntrinsicWidth();
      mIndicatorMaxPos = mViewRect.right - mIndicatorOffset;
      mIndicatorMinPos = mViewRect.left + mIndicatorOffset;
      mBackground.setBounds(mViewRect.left, mViewRect.top, mViewRect.right,
          mViewRect.bottom);
    }
Now it's time to calculate the position of the indicator bar, based on min and max values, offset, etc. Get some local variables to hold the position and edges of the indicator, and calculate the position:
final float pos;
    final int left;
    final int right;
    final int top;
    final int bottom;

    pos = mIndicatorMinPos
        + ((mIndicatorMaxPos - mIndicatorMinPos) / (mMax - mMin))
        * (mPosition - mMin);
When setting the drawing bounds for the indicator, there are some calculations to be made, since pos above is the centerpoint of the indicator, and not one of the edges:
left = (int) pos - (mIndicator.getIntrinsicWidth() / 2);
    top = mViewRect.centerY() - (mIndicator.getIntrinsicHeight() / 2);
    right = left + mIndicator.getIntrinsicWidth();
    bottom = top + mIndicator.getIntrinsicHeight();
    mIndicator.setBounds(left, top, right, bottom);
And finally, it's time to draw (don't forget the closing bracket):
mBackground.draw(canvas);
    mIndicator.draw(canvas);
  }
As you have perhaps already figured out, the system will know where to draw the bitmaps through the call to setBounds() for each bitmap. The rest where just there to calculate the bounds.

Step 2.4 onMeasure


git checkout step_2_4

One more thing needs to be added to the CustomView class, before we can start using it in step 2.5. The layout engine still don't know how large our view is. To find out, it will need to measure the view. Whenever that happens, there will be a call to onMeasure().
The implementation of onMeasure() is far simpler than onDraw(). All that is needed is to specify the width and the height of the view.

First of all there needs to be a call to the super.onMeasure(). This will set the width and height to maximum possible values, based on your layout parameters, such as fill_parent (or match_parent) and wrap_contents, margins, etc.:
@Override
  protected void onMeasure(final int widthMeasureSpec,
     final int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
For horizontal rendering of the slider view, the width should be the maximum possible, but the height should be limited to the height of the indicator (if you use the bitmaps from my sample project.):
setMeasuredDimension(getMeasuredWidth(), mIndicator.getIntrinsicHeight());
  }
As usual, don't forget the closing bracket. (If you are wondering where the code for vertical rendering is, you will have to be patient, as it won't be added until step 5.)

Phew, that's it with the implementation for now. It will still not show up on screen, though. That is because it hasn't been added to the main.xml layout file. So let's do that.

Step 2.5 Use the view.


git checkout step_2_5

The final thing to do in order to see the work we have just done is to replace the SeekBar with the CustomSlider. There is only one thing to change to do that - In the xml file, replace SeekBar with com.enea.training.customview.CustomSlider. Note that the package name must be included, or the view will not be found by the layout engine. Here is what that edited line should look like:
<com.enea.training.customview.CustomSlider android:id="@+id/slider"

If you run your project now, you should see the new shiny slider stretched horizontally across the top of the screen. Tilt your Android device between landscape and portrait and you will see that the bitmaps are nicely stretched. You may also want to change the constructor to initialise mPosition to different values and see that the indiator is placed correctly. Usually the end points are the trickiest ones to get right, so try setting mPosition to mMin and mMax respectively and try the view in both portrait and landscape to make sure it looks good.
SeekBar replaced with CustomSlider
Custom
There are a few situations when this would be all you want to do with a view, but most likely you want to be able to change the slider indicator from your activity and also interact with it by touching it. Or maybe you want a vertical slider instead?

Finally, if you want simple ways of reusing it, you probably want to be able to set things like min and max directly in the xml file.

So let's do all that. Get some fresh coffee and head over to the second part of this tutorial.





Please use the comments field if you have any questions.

/Robert

5 comments:

  1. Nice article...
    http://androidbasic-answer.blogspot.in/

    ReplyDelete
  2. hi, I'm trying to work this to suit my project with custom images that aren't patch 9's. I need to resize the vertical seek bar to fit a layout...any tips?

    ReplyDelete
  3. good project !
    I would like to create some custom view for my home automation application.

    ReplyDelete