Android UI Tutorial – Odometer Part II: Completing the Spinner

[This is the second part in a three-part tutorial for creating an odometer widget in android. If you haven’t already, check out Part 1 to get up to speed on where we are. You can also grab the source code we ended up with at the end of Part 1 to follow along.]

Odometer Tutorial
  1. Part I: First Steps
  2. Part II: Touch Input and Scrolling Numbers « You are here
  3. Part III: Compound Widgets and Fixing Bugs

Adding Interactivity

Now that we have the basic display for our widget down, it is time to make it interactive by working with touch events. But before we do that, there is a small change we need to make that will make moving forward easier.

A Numbers Game

Up to this point, we have been treating the digit we are displaying as just a string. Since we were only drawing that one digit, and it wasn’t changing, it wasn’t a big deal – but now that we are going to be changing our numbers, we should change how we deal with the digit internally. We will still store the string version of the current digit to keep from having to convert it every time we draw, but now we will add an int to store the numeric value as well. We will also add a method, setCurrentDigit(int), that will take in an int value and set the int and string values as appropriate.

private int mCurrentDigit;
//...
public void setCurrentDigit(int digit)
{
	/*
	 *  Basic range limiting - in a production widget,
	 *  you might want to throw an exception if the number passed
	 *  if less than 0 or greater than 9
	 */
	int newVal = digit;

	if(newVal < 0)
		newVal = 0;
	if(newVal > 9)
		newVal = 9;

	mCurrentDigit = newVal;
	mDigitString = String.valueOf(mCurrentDigit);

	setDigitYValues();
	invalidate();
}

The new function setCurrentDigit() does some basic range checking so that we only deal with single-digit, non-negative numbers, but in a production environment, you would want to add some additional handling of these conditions so that you aren’t producing unexpected behavior (hey! i set the spinner to show -42! What’s up?). We set the digit value and the string representation, and then we call some new functions, setDigitYValues() and invalidate(). We will implement setDigitYValues() in a bit, but invalidate() is a method provided us by View, and it tells the system to redraw our widget. Since we changed the number we want to draw, we should probably make sure it gets shown, right?

private void setDigitYValues()
{
	mDigitY = findCenterY(mCurrentDigit);
}

private float findCenterY(int digit)
{
	String text = String.valueOf(digit);
	Rect bounds = new Rect();
	mDigitPaint.getTextBounds(text, 0, text.length(), bounds);

	int textHeight = Math.abs(bounds.height());

	float result = mHeight - ((mHeight - textHeight) / 2);

	return result;
}

You will also need to add the two methods above, which sets the correct Y value for the digit we want to draw. The logic now in findCenterY() was previously in onSizeChanged(), so you will also need to remove that, and just replace it with a call to setDigitYValues(). The last thing to do is to get rid of the old digit initialization in initialize(), and change it to call setCurrentDigit(). It should look something like this:

private void initialize()
{
	//... other code here ...
	setCurrentDigit(4);
}
protected void onSizeChanged(int w, int h, int oldw, int oldh)
{
	//... other code here ...
	mDigitX = mWidth / 2;

	setDigitYValues();
}

Now, if we run our app, we can see the new number displayed – and see that it does still work!

Our OdometerSPinner now displaying 4
New Dig(it)s

Touch Me, Feel Me

Now that we have our handling for the current digit better setup, we can start changing it. Rather than trying to get perfect touch interactivity right off the bat, we will take small steps that gradually get us closer to having that scrolling interaction we are looking for. The first thing we will do is allow the user to ‘tap’ the spinner to increment the digit that is displayed.

To handle user touch input, we need to override onTouchEvent(). This method is called when there is a touch motion event within our View, such as the user touching the screen, moving their finger around, or lifting up the finger. There are many different ‘Actions’ that we can handle, and we determine which Action the passed in MotionEvent encompasses by calling MotionEvent.getAction(). onTouchEvent() also returns a bool, which should be true if the event passed was handled by our code, or false if not.

The two actions we will handle right now will be ACTION_DOWN and ACTION_UP, which correspond to the user putting a finger down on the screen over our View, and lifting that finger up, respectively. We don’t want to do anything when the user touches their finger down, so we just return true. When the user lifts their finger, however, we want to increment the number we display (wrapping after 9), so we change the current digit when that event occurs.

public boolean onTouchEvent(MotionEvent event)
{
	// Pull out the Action value from the event for processing
	int action = event.getAction();

	if(action == MotionEvent.ACTION_DOWN)
	{
		return true;
	}
	else if(action == MotionEvent.ACTION_UP)
	{
		int newValue = mCurrentDigit + 1;

		if(newValue > 9)
			newValue = 0;

		setCurrentDigit(newValue);

		return true;
	}
	return false;
}

If we run our app now, you can tap the spinner to increment the number (click in the emulator).

After touching, our spinner now displays 5
Touch to increment

Such a Drag

Now that we have some basic touch interaction, let’s add the next layer – dragging the digit. Now, if you drag your finger around the screen, the number doesn’t move, it just changes when you lift your finger. We want the user to drag the number up and down to change it, so we need to add that. To begin with, we will add some handling of the ACTION_MOVE action in onTouchEvent(), so that we can move the digit up or down.

We first add two new variables to OdometerSpinner, to store the Y value of where the user started the drag, and another to store the last y-value of the drag. Then, we set those in ACTION_DOWN, and handle changing the digit y-value based on where the user’s finger is while they drag it around.

public boolean onTouchEvent(MotionEvent event)
{
	// Pull out the Action value from the event for processing
	int action = event.getAction();

	if(action == MotionEvent.ACTION_DOWN)
	{
		mTouchStartY = event.getY();
		mTouchLastY = mTouchStartY;

		return true;
	}
	else if(action == MotionEvent.ACTION_MOVE)
	{
		float currentY = event.getY();

		float delta = mTouchLastY - currentY;
		mTouchLastY = currentY;

		mDigitY -= delta;

		invalidate();

		return true;
	}
	//... ACTION_UP...
}

We also call invalidate() during the drag to redraw the View with the new location of the digit. If you run the app at this point, you will see that you can drag the digit up and down, and it moves around like you would expect. When you lift your finger, the number also increments.

Dragging the number now moves it along with our finger
Dragging the digit

There are still improvements to be made, however. Regardless of which direction you drag the number, it only increases in value – and it also increases even if you tap without moving the number very much.

In order to tell if we should increase, decrease, or not change the digit we display, we need to add some conditional logic to the ACTION_UP handler. When the user lifts their finger, we compare the current y-value of their finger to the initial position (which we saved in ACTION_DOWN), and if it is above where they started, they have dragged the number up, so we should decrement the digit. If the y-value is below the starting point, we increment. We also check that the change in y (how far the user dragged) is above a certain threshold (in this case, one third the height of the spinner), or else we don’t change the value at all. Remember, because of how the graphics coordinate system is set up, higher y-values go down, so a negative delta means the user dragged down, so we increase the number (confused yet? It all makes sense, I promise!).

public boolean onTouchEvent(MotionEvent event)
{
	// ... ACTION_DOWN and ACTION_MOVE ...
	else if(action == MotionEvent.ACTION_UP)
	{
		float currentY = event.getY();

		// delta: negative means a down 'scroll'
		float deltaY = mTouchStartY - currentY;

		int newValue = mCurrentDigit;

		if(Math.abs(deltaY) > (mHeight / 3) )
		{
			// higher numbers are 'above' the current, so a scroll down
			// _increases_ the value
			if(deltaY < 0)
			{
				++newValue;
				if(newValue > 9)
					newValue = 0;
			}
			else
			{
				--newValue;
				if(newValue < 0)
					newValue = 9;
			}
		}

		setCurrentDigit(newValue);

		return true;
	}
	return false;
}

Also note that we call setCurrentDigit() even if we didn’t change the value of the digit we are displaying, because it resets the position of the digit to the center of the screen, which we want to happen regardless of whether the number we are displaying changed or not. Run the app and see how much more intuitive changing the number is now. Simply drag up to decrease, and drag down to increase.

Drawing More Numbers

In a real Odometer, as a number is changing, you can see both the current value and the next, since the numbers are usually fairly close together vertically. Right now the user can’t see what the next number is until they lift their finger, so until they change the number a few times, they don’t know what result their action will have. What we need to do is display the numbers above and below the current number as the user drags it up or down.

In OdometerSpinner, add new variables for storing the two new digits – above and below- remembering that we track their integer value, string representation, and y-position. Next, in setCurrentDigit(), set the values of the above and below digits based on the current digit.

private int mDigitAbove;
private int mDigitBelow;
private float mDigitAboveY;
private float mDigitBelowY;
private String mDigitAboveString;
private String mDigitBelowString;
//...
public void setCurrentDigit(int digit)
{
	// Basic range limiting
	int newVal = digit;
	
	if(newVal < 0)
		newVal = 0;
	if(newVal > 9)
		newVal = 9;
	
	mCurrentDigit = newVal;
	
	// Digit above - greater
	mDigitAbove = mCurrentDigit + 1;
	
	if(mDigitAbove > 9)
		mDigitAbove = 0;
	
	// digit below - lower
	mDigitBelow = mCurrentDigit - 1;
	
	if(mDigitBelow < 0)
		mDigitBelow = 9;

	mDigitString = String.valueOf(mCurrentDigit);
	mDigitAboveString = String.valueOf(mDigitAbove);
	mDigitBelowString = String.valueOf(mDigitBelow);
	
	setDigitYValues();
	invalidate();
}

We also need to calculate the starting y-values for our above and below digits, so add them to setDigitYValues(). We also need to draw our new digits, so add them to onDraw().

private void setDigitYValues()
{
	mDigitY = findCenterY(mCurrentDigit);
	mDigitAboveY = findCenterY(mDigitAbove) - mHeight;
	mDigitBelowY = mHeight + findCenterY(mDigitBelow);
}
//...
protected void onDraw(Canvas canvas)
{
	super.onDraw(canvas);
	
	mBGGrad.draw(canvas);
	
	canvas.drawText(mDigitString, mDigitX, mDigitY, mDigitPaint);

	canvas.drawText(mDigitAboveString, mDigitX, mDigitAboveY, mDigitPaint);
	canvas.drawText(mDigitBelowString, mDigitX, mDigitBelowY, mDigitPaint);
}

Finally, in the ACTION_MOVE handling, change the y-values for the above and below digits along with the current digit so they scroll into view as the current digit leaves. We can also now change the code in ACTION_UP to change to the above or below digit instead of doing the calculations there.

public boolean onTouchEvent(MotionEvent event)
{
	// Pull out the Action value from the event for processing
	int action = event.getAction();
	
	if(action == MotionEvent.ACTION_DOWN)
	{
		mTouchStartY = event.getY();
		mTouchLastY = mTouchStartY;
		
		return true;
	}
	else if(action == MotionEvent.ACTION_MOVE)
	{
		float currentY = event.getY();
		
		float delta = mTouchLastY - currentY;
		mTouchLastY = currentY;
		
		mDigitY -= delta;
		mDigitAboveY -= delta;
		mDigitBelowY -= delta;
		
		invalidate();
		
		return true;
	}
	else if(action == MotionEvent.ACTION_UP)
	{
		float currentY = event.getY();
		
		// delta: negative means a down 'scroll'
		float deltaY = mTouchStartY - currentY;
		
		int newValue = mCurrentDigit;
		
		if(Math.abs(deltaY) > (mHeight / 3) )
		{
			// higher numbers are 'above' the current, so a scroll down 
			// _increases_ the value
			if(deltaY < 0)
			{
				newValue = mDigitAbove;
			}
			else
			{
				newValue = mDigitBelow;
			}
		}
		
		setCurrentDigit(newValue);
		
		return true;
	}
	return false;
}

Run the app and play around with it – it should work much like you would expect a spinner to work. You can drag the number up or down to change it, and the new number will scroll into view before being set.

Dragging up and down now shows the multiple=
Showing multiple digits
Source

Grab the source for the completed Part II here. (ZIP)

In part 3 of the tutorial, we will combine multiple spinners into an odometer, and handle some final cleanup.

Odometer Tutorial
  1. Part I: First Steps
  2. Part II: Touch Input and Scrolling Numbers « You are here
  3. Part III: Compound Widgets and Fixing Bugs

One thought on “Android UI Tutorial – Odometer Part II: Completing the Spinner”

  1. Hey just wanted to give you a brief heads up and let you know a
    few of the pictures aren’t loading properly. I’m not sure why but I think its a linking issue.
    I’ve tried it in two different web browsers and both show the same results.

Comments are closed.