Wednesday, October 19, 2011

Creating a custom view, part 3 - Xml Attributes

This is the final part in the series of articles describing how to create a custom view.
For the other parts, follow these links:


In this part, you will see how to make your slider vertical, and how to set attributes from within xml, just the way you do with all the built-in views.

Step 5 Vertical slider


Making the slider vertical is slightly more challenging than step 4. The view must be aware of its orientation, and all position calculations must detect orientation. The complexity of this step means that we are back to doing sub-steps.

Step 5.1 A Vertical member.


git checkout step_5_1

The orientation of the slider can only be vertical or horizontal (I leave it to you as an exercise to implement a diagonal slider), so storing orientation can be done in a boolean:
private boolean mIsVertical;

mIsVertical will need to be initialised in the constructor. It's just going to be hard-coded to true here, until things improve in step 6.
Near the top of the constructor, just after the call to super, this line should be added:
mIsVertical = true;

Obviously, the slider will not become vertical just because of this, that's why there are a few more steps.

Step 5.2 Pick your bitmaps.


git checkout step_5_2

Now that there is a boolean to check for orientation, it's time to act upon it. The first thing to do is to select the correct bitmaps. In the constructor, add an if statement that will initalise the mIndicator and mBackground drawables accordingly:
if (mIsVertical) {
      mIndicator = res.getDrawable(R.drawable.indicator_vertical);
      mBackground = res.getDrawable(R.drawable.background_vertical);
    } else {
      mIndicator = res.getDrawable(R.drawable.indicator_horizontal);
      mBackground = res.getDrawable(R.drawable.background_horizontal);
    }

Step 5.3 onTouchListener revisited.


git checkout step_5_3

The calculations in the touch listener should also be updated. This is where it becomes a bit tricky. For horizontal orientation the mMin corresponds to mIndicatorMinPos, that is pixels and values are growing in the same direction. Whereas for vertical orientation mMin corresponds to mIndicatorMaxPos, that is pixels and values grow in opposite directions.
In the touch listener that is located in the constructor, surround the calculations with the following if-statement:
if (mIsVertical) {
          pos = (mMax - ((mMax - mMin) / (mIndicatorMinPos - mIndicatorMaxPos))
              * event.getY());
        } else {
          pos = (mMin + ((mMax - mMin) / (mIndicatorMaxPos - mIndicatorMinPos))
              * event.getX());
        }
Note how the calculation of pos differs.

Step 5.4 Improve the drawings.


git checkout step_5_4

The onDraw() and onMeasure() will also need to be dependent on the orientation. Let's do onDraw() first.
The first part of onDraw() initialises indicator min, max and offset values, in relation to the view's drawing rectangle. Surround the initialisation with the following if statement:
if (mIsVertical) {
        mIndicatorOffset = mIndicator.getIntrinsicHeight();
        mIndicatorMaxPos = mViewRect.top + mIndicatorOffset;
        mIndicatorMinPos = mViewRect.bottom - mIndicatorOffset;
      } else {
        mIndicatorOffset = mIndicator.getIntrinsicWidth();
        mIndicatorMaxPos = mViewRect.right - mIndicatorOffset;
        mIndicatorMinPos = mViewRect.left + mIndicatorOffset;
      }
Note how max and min relates to the mViewRect coordinates.

The second part of onDraw() calculates the real position of the indicator, based on the values above. Only pos and top left corner will need to modified for the indicator, since the other corners are relative to this one. Surround the calculations with the following:
if (mIsVertical) {
      pos = mIndicatorMaxPos
          + ((mIndicatorMinPos - mIndicatorMaxPos) / (mMax - mMin))
          * (mMax - mPosition);
      left = mViewRect.centerX() - (mIndicator.getIntrinsicWidth() / 2);
      top = (int) pos - (mIndicator.getIntrinsicHeight() / 2);
    } else {
      pos = mIndicatorMinPos
          + ((mIndicatorMaxPos - mIndicatorMinPos) / (mMax - mMin))
          * (mPosition - mMin);
      left = (int) pos - (mIndicator.getIntrinsicWidth() / 2);
      top = mViewRect.centerY() - (mIndicator.getIntrinsicHeight() / 2);
    }

Finally, onMeasure() will need to be modified. This is much simpler, since no calculations are needed. Surround the setter with the following:
if (mIsVertical) {
      setMeasuredDimension(mIndicator.getIntrinsicWidth(), getMeasuredHeight());
    } else {
      setMeasuredDimension(getMeasuredWidth(), mIndicator.getIntrinsicHeight());
    }

Done. You are now finished with step 5. Run your project and make sure the slider works as expected. Change the initialisation of mIsVertical in the constructor to make sure that you haven't broken the horizontal layout.
Note that when rendering the slider vertically, it pushes the reset button off the screen. Correcting it would require a modification of your activity's layout, which will be done next.

Step 6 Add key-value attributes in xml.


Almost there, the only thing left in this tutorial is to add some attributes that can be set from within the xml layout. Let's start with specifying the attributes.
I'm not going to go too far, but at least it makes sense to be able to set orientation, min and max from the xml. Once you've seen how to do this, adding other attributes should be easy.

Step 6.1 xml modifications.


git checkout step_6_1

Right-click on your project and select New->Android xml file.
Specify the file name "attrs.xml" and select the Values radio button. Click Finish to generate the file.
Open up the xml view of attrs.xml. This is where the attributes should go.
The node to use is called declare-stylable, and is used to identify a group of attributes. The sub-nodes are all of type attr and contains name and type of the attribute. Min and max are floats, whereas orientation is vertical or horizontal.
Your attrs.xml file should look like this:
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <declare-styleable name="CustomSlider">
    <attr name="orientation">
      <enum name="horizontal" value="0" />
      <enum name="vertical" value="1" />
    </attr>
    <attr name="max" format="float"/>
    <attr name="min" format="float"/>
  </declare-styleable>
</resources>
This provides three key-value pairs that can be set for the slider. It's worth noting that there are other ways of specifying attributes. For example, if you want to re-use the enum for orientation, you may declare it outside of the
tag and just refer to it. Just ask, and I will in the comments describe how to do that.

Step 6.2 Modified constructor.


git checkout step_6_2

There is one more thing to do to get this to work - modify the constructor to make use of the new attributes.
This is really quite easy. One of the input parameters to the constructor is an AttributeSet, containing all the attributes for your view.

From that, you need to extract the attributes you are interested in.

At the top of your constructor, right after the call to super(), you should add the following lines to get a TypedArray with the attributes:
final TypedArray a = context.obtainStyledAttributes(attrs,
        R.styleable.CustomSlider);
Now, instead of just hard-coding mIsVertical to true or false, you should grab the orientation from your attributes. But since the orientation attribute
translates horizontal/vertical to an integer, based on the enum, you will need your java code to turn it into true or false. This code should replace the "mIsVertical=true" statement:
final int vertical = a.getInt(R.styleable.CustomSlider_orientation, 0);
    mIsVertical = (vertical != 0);
Look at the help for getInt() and make sure that you understand what the parameters mean.

The min and max values are done in a similar way, but they are already floats, so no extra treatment is necessary. Replace the mMin and mMax initialisation statements with the following:
final int max = a.getInt(R.>final float max = a.getFloat(R.styleable.CustomSlider_max, 1.0f);
    final float min = a.getFloat(R.styleable.CustomSlider_min, -1.0f);
    setMinMax(min, max);

Ok, done with the slider. Let's put that latest piece of code to some use.

Step 6.3 Attributes in layout


git checkout step_6_3

Once the new attributes are declared, you can refer to them from your main.xml layout file.
You will need to add the namespace to the layout and then obviously set the attributes for the CustomSlider.

The namespace is an attribute for the RelativeLayout, so the first few lines in main.xml should now look like this:

Once that is added, it's just a matter of setting the attributes for the slider views. The updated slider tag should look like this:
<com.enea.training.customview.CustomSlider android:id="@+id/slider_horizontal"
      CustomView:orientation="horizontal"
      CustomView:min="0.0"
      CustomView:max="100.0"
      android:layout_height="wrap_content"
      android:layout_width="fill_parent"
      android:layout_alignParentLeft="true"
      android:layout_alignParentTop="true"
    />
To make things a bit more interesting, you could add another slider, and make it vertical:
<com.enea.training.customview.CustomSlider android:id="@+id/slider_vertical"
      CustomView:orientation="vertical"
      CustomView:min="-50.0"
      CustomView:max="50.0"
      android:layout_height="fill_parent"
      android:layout_width="wrap_content"
      android:layout_alignParentLeft="true"
      android:layout_below="@+id/slider_horizontal"
      android:layout_alignParentBottom="true"
    />

As you can see, the new attributes are being set exactly the same way as the standard attributes, the only difference is the namespace.

That new slider needs some code in the activity as well. You will need a new member field and a new position listener, and displayValues() will need to display the vertical value.
Furthermore, since the horizontal slider now gets the initial values from xml, there is no need to call the setters in onCreate().
The onReset() callback method for the button will also need to be updated to reset both sliders.
Here is the complete listing of the activity. As you can see, it's just a matter of duplicating the code of the horizontal slider for the vertical.
public class CustomViewActivity extends Activity {
  private TextView     mValues;
  private CustomSlider mSliderHorizontal;
  private CustomSlider mSliderVertical;

  @Override
  public void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    mValues = (TextView) findViewById(R.id.values);

    mSliderHorizontal = (CustomSlider) findViewById(R.id.slider_horizontal);
    mSliderHorizontal.setPositionListener(new CustomSliderPositionListener() {
      public void onPositionChange(final float newPosition) {
        displayValues();
      }
    });
    mSliderVertical = (CustomSlider) findViewById(R.id.slider_vertical);
    mSliderVertical.setPositionListener(new CustomSliderPositionListener() {
      public void onPositionChange(final float newPosition) {
        displayValues();
      }
    });

    displayValues();
  }

  void displayValues() {
    final String str = String.format("Horizontal: %3.2f\nVertical: %3.2f",
        mSliderHorizontal.getPosition(), mSliderVertical.getPosition());
    mValues.setText(str);
  }

  public void onReset(final View v) {
    float min = mSliderHorizontal.getMin();
    float max = mSliderHorizontal.getMax();
    float newPos = (max - min) / 2 + min;
    mSliderHorizontal.setPosition(newPos);

    min = mSliderVertical.getMin();
    max = mSliderVertical.getMax();
    newPos = (max - min) / 2 + min;
    mSliderVertical.setPosition(newPos);
  }
}


That's it! Try your application again, perhaps a few times while changing the attributes in main.xml.




Where to go from here

Even though this series of articles is a long read, the actual steps are not that difficult.

The view just implemented is far from complete, but I do believe it's a good starting point. Things you may want to add are:

  • Make the view retain values between orientation changes.
  • Different colour of the indicator when touched, like the standard views.
  • React to long clicks or double clicks.
  • Displaying the value within the view instead of a separate view.
  • Animations, 3D graphics.
Keep in mind though, that this example extends a standard View. If you only want to modify one of the already existing views, for example a new behaviour for a button, you can opt for the much simpler route of just extending that type of view and only modify the things you need.

Now go and create astonishing views for your applications, and feel free to use the comments field below for any questions.

If you don't want to use the back button to read the articles again, you can use these links:

Thanks for reading. Please use the comments field if you have any questions.

/Robert

5 comments:

  1. Hi!

    I have completely copied your final code and the background of the slider is blurred away. It seems like the code can't use the 9patch files.

    Do you have any idea why?

    Thanks,
    Pepe

    ReplyDelete
  2. sorry, the 'step_6_3' works fine.

    ReplyDelete
  3. Hi,

    Great Tutorial, you have literally saved my life.
    I'm currently working on a project where I have to represent an Analog Knob, following your instructions I could successfully make it work, but I still have some questions I hope you could help me with.
    How to make the View scalable? I mean, to make it bigger or smaller depending on the drawable area; and, How to limit the Touch/Click area to only the Texture area? so it doesn't respond to events when the user clicks outside the view bounds.

    Hope you could give me a hand,
    Rgds
    Andrés

    ReplyDelete
  4. I'm happy you found the tutorial useful.
    The slider in the tutorial is already scalable, which can easiest be seen if you compare the lengths of the horizontal and the vertical sliders in the screenshots. The key to this is to use 9-patch bitmaps. If your analog knob is circular or of some irregular shape, the 9-patch design could be really tricky. In that case it might be easiest to use a set of bitmaps for all sizes you want to handle and select the correct one when the view is created. Or, if you want to handle all sizes, you can draw the bitmap using the canvas operations (circles, rectangles, etc). For performance you could draw it once and cache the resulting bitmap for as long as the view keeps the size.

    The click area is even worse to deal with. All views in Android are rectangular, so if you want a non-rectangular click area you must check that in your onClick()-implementation. Compare the click-position with a location in your bitmap and return true for hits and false for misses.

    Good luck and feel free to share your solution here (or at least tell me which solution you went for.)

    /Robert

    ReplyDelete