Home Development for Android Creating a custom component from scratch. Part 1

Creating a custom component from scratch. Part 1

by admin

Introduction

Greetings, colleagues!
Quite often when developing Android multimedia applications (hereafter simply "applications") we are faced with the task of creating our own components that are not provided in the system. These can be all sorts of knobs-switches, spectrum visualizers, etc. Some of them can be obtained simply by replacing a graphic resource, rotating the canvas by 90 degrees, etc. But, sometimes, you still have to make something of your own from scratch.
In this article I’m going to talk about creating a component, a simple piano keyboard, by inheriting from the View class and implementing all the internals "by yourself". Why in quotes, you’ll see next.
In a series of articles I will try to cover such issues as :

  1. component drawing
  2. Adding scrolling with standard scrollbars
  3. interaction, using selectors for keys
  4. Saving the state of a component when the screen is rotated
  5. Adding backlight on overscroll
  6. XML parameter transfer
  7. pinch zoom

The first article will be about the first three items.
If you are interested in these topics, welcome under the cat.

Background

Once upon a time, when I was writing my music app, which I described in previous articles, I was confronted with the need to write a piano. Since it was my very first android app, and android wasn’t at all like it is now, I did far more than one perversion in the first version to make a less-than-working component. I kept a giant Bitmap made of 4 octave pictures in memory, for scrolling I had a separate thread which cyclically reduced the scrolling speed after a given interval and fell asleep, until I got the next task. There was zero interactivity.
Now, some time later, I am writing a project which is a lot like my first one, but with completely different quality and functionality, and I need a piano again. That’s what I’m going to talk about.

Component development

View or SurfaceView?

The rule of thumb I’ve deduced for myself is to try to use View whenever possible and avoid SurfaceView unless you need to have a component that constantly draws some changing state with more or less complex graphics (game, video). In all other cases View is your choice. You also need to consider that by using SurfaceView we deprive ourselves of the possibility to animate this component inside your layout in the future.

Initial stage

Okay, here we go, the first thing we’ll do is create a new class, a descendant of android.view.View. Let’s call it PianoView.

public class PianoView extends View {public PianoView(Context context, AttributeSet attrs) {super(context, attrs);}@Overrideprotected void onDraw(Canvas canvas) {super.onDraw(canvas);}}

As you can see, we have a constructor in which we pass the context and a set of attributes. In the onDrawmethod we will draw our component. This method is called whenever it becomes necessary to redraw the view, for example on every animation frame.

Keyboard Drawing. Graphic Resources.

To draw the keys I will use the standard Android tools: selector, nine-patch drawable.
For the white keys I made the following 9-patch images. I decided to make the highlighted state using the standard Holo blue backlight.
Creating a custom component from scratch. Part 1
For black :
Creating a custom component from scratch. Part 1
And created a selector for each of them :

<?xml version="1.0" encoding="utf-8"?> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@drawable/white_key_pressed" android:state_pressed="true"> </item> <item android:drawable="@drawable/white_key"> </item> </selector>

All that’s left now is to get these Drawables in the code with context.getResourses.getDrawable();

Keyboard drawing. Code

So, to keep the component code clean, I put all the rendering of the keyboard and storing the necessary information for it into the Keyboard class. In our onDraw I will simply call its method :

protected void onDraw(Canvas canvas) {if (measurementChanged) {measurementChanged = false;keyboard.initializeInstrument(getMeasuredHeight(), getContext());instrumentWidth = keyboard.getWidth();}keyboard.draw(canvas);}

I won’t go into very much detail about how the piano is rendered simply because it would take up too much space with tedious text and code. Anyone who wants to look at the details can take my code and see. Here I will only explain the principle.
The first step is initialization. Initialization involves calculating the key array.

Key[] keysArray;

This is our model. Each element is a key. The key knows its coordinates (in the component’s coordinate system) and size, whether it is black or white, and whether it is currently pressed or not.

class Key {float startX;float endX;float startY;float endY;int midiCode;boolean black;boolean pressed = false;void setBounds(float startX, float endX, float startY, float endY) {this.startX = startX;this.startY = startY;this.endX = endX;this.endY = endY;}boolean containsPoint(float x, float y) {return startX <= x endX > x startY <= y endY > y;}}

This process happens every time the physical size of our component changes, our keyboard is initialized (this is done by the measurementChanged flag, which we just set to true in the onMeasure method). That way we won’t have to calculate the key positions every time we draw it.
Initialization code

public void initializeInstrument(float measuredHeight, Context context) {whiteKeyWidth = Math.round(measuredHeight / WHITE_KEY_ASPECT_RATIO);octaveWidth = whiteKeyWidth * 7;int blackHalfWidth = octaveWidth / 20;blackKeyHeight = Math.round(measuredHeight / BLACK_KEY_HEIGHT_PERCENT);keysArray = new Key[KEYS_IN_OCTAVE * OCTAVES];int whiteIndex = 0;int blackIndex = 0;for (int i = 0; i < KEYS_IN_OCTAVE; i++) {Key key = new Key();if (isWhite(i)) {key.black = false;key.setBounds(whiteKeyWidth * whiteIndex, whiteKeyWidth * whiteIndex + whiteKeyWidth, 0, measuredHeight);whiteIndex++;} else {key.black = true;int indexDisplacement = i == 1 || i == 3 ? 1 : 2;key.setBounds(whiteKeyWidth * (blackIndex + indexDisplacement) - blackHalfWidth, whiteKeyWidth* (blackIndex + indexDisplacement) + blackHalfWidth, 0, blackKeyHeight);blackIndex++;}key.midiCode = START_MIDI_CODE + i;keysArray[i] = key;}for (int i = KEYS_IN_OCTAVE; i < KEYS_IN_OCTAVE * OCTAVES; i++) {Key firstOctaveKey = keysArray[i % KEYS_IN_OCTAVE];Key key = firstOctaveKey.clone();key.startX += (i / KEYS_IN_OCTAVE) * octaveWidth;key.endX += (i / KEYS_IN_OCTAVE) * octaveWidth;key.midiCode = START_MIDI_CODE + i;keysArray[i] = key;}}

Here we calculate the width of the keys based on the height of the component and build an array of keys. First the first octave is built, then it is cloned and shifted along the X axis as many times as needed to get the other octaves. Also, we will have a MIDI code corresponding to each key to playback the sound on. Midi codes have a sequential numbering. The code of the first key will be START_MIDI_CODE. The code of any key is calculated by adding the start code and the key index to an array.
Next, we draw the keys. In the loop over the whole array of keys we draw as follows :

private void drawSingleKey(Canvas canvas, Key key, int firstVisibleKey, int lastVisibleKey) {Drawable drawable = key.black ? blackKeyDrawable : whiteKeyDrawable;drawable.setState(new int[] { key.pressed ? android.R.attr.state_pressed : -android.R.attr.state_pressed });drawable.setBounds((int) key.startX, (int) key.startY, (int) key.endX, (int) key.endY);drawable.draw(canvas);}

Drawing takes place in 2 stages, because we need to draw the white keys first, then the black keys, so that there is no overlap. We could have avoided this by making the 9-patches for the keys not rectangular, with notches. Moreover, it could have helped us remove unnecessary pixel redrawing, but for the purposes of this article, let’s keep things as primitive as possible.
Done, our tool is successfully drawn :
Creating a custom component from scratch. Part 1
Not bad. Of course, nothing happens when you click the keys now. Let’s fix that.

Key Interaction

To interact with user presses, it’s common to override the onTouchEvent method and define in it what the user did – touched a finger, performed a gesture, double-touch, prolonged touch, etc. Fortunately, in most cases you and I are spared this kind of trouble.
We’ll use the GestureDetector class, kindly provided by the platform since its early days.
Let’s add the field private GestureDetector gestureDetector; and initialize it

private void init() {if (!isInEditMode()) {gestureDetector = new GestureDetector(getContext(), gestureListener);}}

In the constructor we pass the listener gestureListener, this is where we get callbacks from the detector when some gesture is detected.

private OnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() {public boolean onDown(MotionEvent e) {if (keyboard.touchItem(e.getX(), e.getY())) {invalidate();}return true;}public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {resetTouchFeedback();return true;}public boolean onSingleTapUp(MotionEvent e) {resetTouchFeedback();return super.onSingleTapUp(e);}};

So, the algorithm is simple, in the onDown method we pass the coordinates of pressing to our keyboard, where we search for the pressed key (method touchItem calculates the index of the key without the need to scan the entire array). If the key is found, it is marked as pressed and we call invalidate, which leads to redrawing.
In the other methods, we reset the pressed key (when scrolling, thumb up, etc.). This is done by analogy with, for example, ListView, when we start scrolling the sheet, the selection is reset.
The next step is to connect the detector to our component. This is very easy to do :

public boolean onTouchEvent(MotionEvent event) {int action = event.getAction();if (action == MotionEvent.ACTION_CANCEL) {resetTouchFeedback();}return super.onTouchEvent(event) || gestureDetector.onTouchEvent(event);}

Note that we also check to see if the action ACTION_CANCEL. and, in that case, we also reset the selection, because the GestureDetector does not react on it, and if it happens, we risk that we are left with the key selected forever.
Checking :
Creating a custom component from scratch. Part 1
Yay, it looks a little more alive now. But we still only see part of the keyboard… No problem, let’s screw scrolling.
Adding scrolling to the component
So, let’s first consider how we’re going to shift our content. The easiest way is not to move anything, but to draw the same way, but to move the canvas itself. The class Canvas allows us to do affine transformations on itself.
Let’s add a simple field

private int xOffset;

to our class.
Now let’s extend our onDraw method with the following construction :

protected void onDraw(Canvas canvas) {if (measurementChanged) {measurementChanged = false;keyboard.initializeInstrument(getMeasuredHeight(), getContext());instrumentWidth = keyboard.getWidth();}canvas.save();canvas.translate(-xOffset, 0);keyboard.updateBounds(xOffset, canvasWidth + xOffset);keyboard.draw(canvas);canvas.restore();}

Let’s take a look at what we did :

  • canvas.save() – stores the current state of canvas. Creates a kind of checkpoint
  • canvas.translate() – shifts canvas by a specified distance
  • canvas.restore() – restores the original canvas state.

We also added the updateBounds method to our Keyboard class. It allows us to pass the left and right visible boundary so that we don’t draw keys that are outside of the screen. The optimization is like this.
Now that we have added support for scrolling in the drawing phase, let’s add it to the user interaction, the GestureDetector. We modify onScroll:

public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {resetTouchFeedback();xOffset += distanceX;if (xOffset < 0) {xOffset = 0;}if (xOffset > instrumentWidth - getMeasuredWidth()) {xOffset = instrumentWidth - getMeasuredWidth();}invalidate();return true;}

Done, now when we move our finger across our keyboard, it scrolls nicely without going beyond the keyboard boundary. But that’s not enough for us. We want to be able to twiddle our finger and let the keyboard wiggle inertially — to do flinging.
Fortunately, we don’t have to calculate the speed of our finger and the distance it travels on the screen. Our favorite GestureDetector does it all for us. We just need to redefine the onFling method. It will tell us what the user has flinged and its initial characteristics. But to track the state of scrolling, to interpolate between the start and end points, we need another component – Scroller, or rather its brother – OverScroller (we want to add glow effects in the future). Scroller is a very useful component for any kind of scrolling in Android, it is used in countless internal components, and implements the standard scrolling behavior.
Let’s add our scroller :

private OverScroller scroller;

and initialize it in the component constructor.
Next, modify the GestureDetector as follows :

private OnGestureListener gestureListener = new GestureDetector.SimpleOnGestureListener() {public boolean onDown(MotionEvent e) {scroller.forceFinished(true);if (keyboard.touchItem(e.getX() / scaleX + xOffset, e.getY())) {invalidate();}return true;}public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {scroller.fling(xOffset, 0, (int) -velocityX, 0, 0, instrumentWidth - getMeasuredWidth(), 0, 0);return true;}// ...};

As you can see from the code, we start the scroller with an initial offset and speed, and give it a minimum and maximum scrolling.
The next step is onDraw

protected void onDraw(Canvas canvas) {if (scroller.computeScrollOffset()) {xOffset = scroller.getCurrX();}if (measurementChanged) {measurementChanged = false;keyboard.initializeInstrument(getMeasuredHeight(), getContext());instrumentWidth = keyboard.getWidth();}canvas.save();canvas.scale(scaleX, 1.0f);canvas.translate(xOffset , 0);keyboard.updateBounds(xOffset , canvasWidth + xOffset );keyboard.draw(canvas);canvas.restore();if (!scroller.isFinished()) {ViewCompat.postInvalidateOnAnimation(this);}}

What has changed here? For every animation frame we call scroller.computeScrollOffset(), this method returns true if the scroller is animated, then we get the current value of variable xOffset.
Since animation implies a series of repaints – at the end of the method we check whether the scroller has finished animating and, if not, we assign the next animation frame. Thus, until the scroller finishes animating, or is stopped by force, the onDraw method will be called as often as possible and draw your component.
Now our component scrolls nicely and supports fling. But something is missing, isn’t it? It lacks the standard scrollbars at the bottom. Not a problem.

Adding standard scollbars

Adding standard scrollbars is like a spell, there aren’t any special mysteries, just a sequence of actions.
First, we have to tell our component that it supports all standard scrolling attributes. To do this, we need to create an attrs.xml file in our directory and add the following definition to it :

<declare-styleable name="View"> <attr name="android:fadeScrollbars" /> <attr name="android:scrollbarAlwaysDrawHorizontalTrack" /> <attr name="android:scrollbarAlwaysDrawVerticalTrack" /> <attr name="android:scrollbarDefaultDelayBeforeFade" /> <attr name="android:scrollbarFadeDuration" /> <attr name="android:scrollbarSize" /> <attr name="android:scrollbarStyle" /> <attr name="android:scrollbarThumbHorizontal" /> <attr name="android:scrollbarThumbVertical" /> <attr name="android:scrollbarTrackHorizontal" /> <attr name="android:scrollbarTrackVertical" /> <attr name="android:scrollbars" /> </declare-styleable>

Now, let’s add :

setVerticalScrollBarEnabled(false);setHorizontalScrollBarEnabled(true);TypedArray a = context.obtainStyledAttributes(R.styleable.View);initializeScrollbars(a);a.recycle();

The next step is to redefine three simple methods in which we will control the sizes and positions of the scrollbars :

protected int computeHorizontalScrollExtent() {return canvasWidth;}@Overrideprotected int computeHorizontalScrollOffset() {return xOffset;}@Overrideprotected int computeHorizontalScrollRange() {return instrumentWidth;}

The code speaks for itself – in the first method we specify the width of our component, in the second, the current scrolling offset, in the third, the size of the entire keyboard (which goes beyond the screen boundaries). Now all we have to do is to "wake up" these scrollbars when needed. The View base class provides a special awakenScrollBars() method to do this. Let’s add the following lines :

if (!awakenScrollBars()) {invalidate();}

to the onScroll and onFling methods of our GestureDetectorListener.
The result is that the standard scrollbars are pleasing to our eyes.
Creating a custom component from scratch. Part 1

Conclusion

So in this part we looked at creating a component, drawing with Drawables, different states of drawables, visual feedback on interaction, scrolling, fling gesture, creating scrollbars.
This article is quite long, so I decided to break it up into several parts.
In following part will tell about :

  1. Saving the state of a component when you rotate the screen
  2. Adding backlight on overscroll
  3. XML parameter transfer
  4. pinch zoom

Also I have plans for the third part, where I will tell about optimization, about the difference between using ready bitmaps and drawing on canvas (drawCircle, drawText, etc.), about getting rid of redrawing and so on. I will write a third article only if readers like the first two and are interested in the third 🙂
The sources of the finished project for this series of articles are on my github at : goo.gl/VDeuw I want to point out right away that these are clippings from under development project, so if you find some code which you don’t need, it’s possible that I forgot to cut it out.

You may also like