Wednesday, October 19, 2011

Creating a custom view, part 2 - Interaction

This is the second part in my article about how to create a custom view in Android.
Please follow these links for the other parts:
In this part of the article, the slider will get some useful functionality, like being able to position the indicator from the activity, and react on touches.

Step 3 Change indicator position from the activity.


This step is far from the complexity in step 2, but still complex enough to warrant a few substeps.

Step 3.1 Setters and getters.


git checkout step_3_1

The requirement for this step was to be able to change the indicator position, but it makes sense to also make it possible to also alter the min and max values.

The getters are easy, but the setters need a few extras.
Let Eclipse do the easy part for you, by right-clicking on your class and selecting Source->Generate Getters and Setters. Tick mMin, mMax and mPosition and make sure to generate both getter and setter for mPosition and mMin. You don't need one for mMax, for reasons that will become clear in a moment.
After the code has been generated, you probably want to tidy up the method names slightly. Your getters should look something like this:
public float getMin() {
    return mMin;
  }

  public float getMax() {
    return mMax;
  }

  public float getPosition() {
    return mPosition;
  }
As I said, the setters require a few extra lines. Since calling a setter will possibly alter a visible view, the view should be invalidated. But since invalidating views trigger a redrawing of it and possibly neighbouring views you should not invalidate unless needed. Furthermore you probably don't want the position to be outside of min and max, so you should check for that. Your setter for mPosition should thus look like this:
public void setPosition(float position) {
    position = within(mMin, mMax);

    if(position != mPosition) {
      mPosition = position;
      invalidate();
    }
  }
You will also need to implement that helper method, within(). It should look something like this:
private float within(float position, final float min, final float max) {
    if (position < min) {
      position = min;
    }
    if (position > max) {
      position = max;
    }
    return position;
  }

Here is the reason for not generating a setter for mMax. For the slider to work properly, mMin must always be smaller than mMax, and the indicator must always be positioned somewhere in between min and max. That's why mMin and mMax must be set in the same method. The setter should check min and max, and throw an exception if they are wrong, and it should also adjust the position of the indicator. The adjustment can be done by reusing the setter for the position, like this (renaming setMin to setMinMax):
public void setMinMax(final float min, final float max) {
    if ((min != mMin) || (max != mMax)) {
      if (min > max) {
        throw new IllegalArgumentException(
            "setMinMax: min must be smaller than max.");
      }
      mMin = min;
      mMax = max;
      setPosition(mPosition);
      invalidate();
    }
  }

Step 3.2 onPositionChanged


git checkout step_3_2

Being able to set and get the position is handy, but you will also need some way for the view to tell the activity that values have changed.
For this you will need a listener interface for your view and your activity needs to implement it in a usual manner. (The activity will be modified in the next step.)
Adding an listener interface is just a matter of specifying what should go in the interface and which object that should be tied to it.
First the interface. Put this into your class:
interface CustomSliderPositionListener {
    void onPositionChange(float newPosition);
  }
A new member field is also needed:
private CustomSliderPositionListener mPositionListener;

The field will need a setter:
public void setPositionListener(final CustomSliderPositionListener listener) {
    mPositionListener = listener;
  }

And it should be initialised in the constructor, so you should add this line to the end of the constructor:
mPositionListener = null;

Finally, the listener should be called if the position does indeed changes, so you will need to add a few lines to setPosition, inside the if statement just after the call to invalidate:
if (mPositionListener != null) {
        mPositionListener.onPositionChange(mPosition);
      }

Ok, done.
(You should really add similar code for getting notified about changes to min and max as well. Feel free to do that, but I will not add it here, for the sake of the size of the tutorial.)

Step 3.3 Update the activity.


git checkout step_3_3

Again, the activity is there to show you that things are working, so let's do just that.
In you main.xml, add a text view and a button. The purpose of the text view is to show the current position of the indicator, and the purpose of the button is to reset it to the centre.
Your new main.xml should look something 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"
    >
    <com.enea.training.customview.CustomSlider android:id="@+id/slider_horizontal"
      android:layout_height="wrap_content"
      android:layout_width="fill_parent"
      android:layout_alignParentLeft="true"
      android:layout_alignParentTop="true"
    />
    <Button android:id="@+id/button_reset"
      android:text="@string/reset"
      android:onClick="onReset"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_alignParentRight="true"
      android:layout_alignParentBottom="true"
      />
    <TextView android:id="@+id/values"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_alignParentRight="true"
      android:layout_above="@id/button_reset"
      />
</RelativeLayout>

Notice that I have changed the id of the slider, this is a hint of what is to come...
You will also need to add a string for your button. Add this to res/values/strings.xml:
<string name="reset">Reset</string>

You will also need to add a fair bit of code to your CustomSliderActivity. First of all you will need two member fields, for the text view and the slider:
private TextView     mValueHorizontal;
  private CustomSlider mSliderHorizontal;

You will also need to initialise th fields in onCreate. That includes implementing the onPositionChanged listener added in step 3.2, and setting min, max and position by calling the setters in step 3.1.
The listener calls to a helper method, displayValues() that will show the position of the indicator properly formatted in a text view.
Your new onCreate(), and the helper method, should look like this:
@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();
      }
    });

    mSliderHorizontal.setMinMax(-100.0f, 100.0f);
    mSliderHorizontal.setPosition(30.0f);
    displayValues();
  }

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

All that is left now is to implement the callback for the button. Upon clicking the button, the indicator should go to the center of the slider. The code should look like this:
public void onReset(final View v) {
    final float min = mSliderHorizontal.getMin();
    final float max = mSliderHorizontal.getMax();
    final float newPos = (max - min) / 2 + min;
    mSliderHorizontal.setPosition(newPos);
  }
Time for another test run of your project. Make sure the code works as expected, and don't forget to click the button.
Modify the hard-coded values in onCreate and verify that the slider reflects your changes.

Step 4 onTouchListener


git checkout step_4

About time to interact a bit more. This is probably the simplest step of them all. All you need to do is in your constructor tell your view that it should use a touch listener:
setOnTouchListener(new OnTouchListener() {
      public boolean onTouch(final View v, final MotionEvent event) {
        final float pos;
        pos = (mMin + ((mMax - mMin) / (mIndicatorMaxPos - mIndicatorMinPos))
              * event.getX());
        setPosition(pos);
        return true;
      }
    });

Done. Run your project and try to move the indicator with your finger. Hit the reset button to get it back to the center.

That's it for this part of the article. In the next part, you will see how to make a vertical slider, and how to set some attributes from xml.


Please use the comments field if you have any questions.

/Robert

6 comments:

  1. This comment has been removed by the author.

    ReplyDelete
  2. Hi, I'm enjoying this tutorial immensely. I noticed a bug!

    Refering back to the previous tutorial section. You are calling "setPosition(mPosition)" inside the setMinMax CustomSlider class.

    I think thats a bug! The reason for mentioning this is that you call them in this section as below, which sets the position correctly:

    mSliderHorizontal.setMinMax(-100.0f, 100.0f);
    mSliderHorizontal.setPosition(30.0f);

    Apologies though if this is my error and misunderstanding.

    ReplyDelete
    Replies
    1. FX Review,
      Glad to hear that you like the tutorial.

      The reason for calling setPosition() from setMinMax() is to ensure that the position is indeed within min and max values.

      /Robert

      Delete
  3. Just stumbled along this tutorial when trying to put buttons/spinners into my custom view. Basically everywhere I looked said it was hopeless, but I didn't give up! I'm so glad I found this tutorial. Thank you very much for saving me so much headache.

    ~Michael

    ReplyDelete