Creating a custom component from scratch. Part 1
Introduction
Greetings, colleagues!
Quite often, when developing multimedia Android applications (hereinafter simply “applications”), we are faced with the task of creating our own components that are not provided for in the system. It can be all kinds of switch knobs, spectrum visualizers, etc. Some of them can be obtained by simply replacing the graphic resource, turning the canvas 90 degrees, etc. But, sometimes, you still have to do something different “with zero. ”
In this article I am going to talk about creating a component - a simple piano keyboard, using inheritance from the View class and implementing all internals “independently”. Why in quotation marks - see further.
In a series of articles I will try to highlight issues such as:
- component rendering
- adding scrolling using standard scrollbars
- interaction, using selectors for keys
- Saving component state when rotating the screen
- adding backlight for overscroll
- passing parameters to XML
- pinch zoom
The first article will be about the first three points.
If you are interested in these topics, welcome to cat.
Background
Once upon a time, when I wrote my musical app, which I talked about in previous articles, I was faced with the need to cut the piano. Since this was my very first android application, and the android then was not at all the same as it is now, in the first version I made far more than one perversion in order to make a less or less working component. I kept in memory a giant Bitmap, cobbled together from 4 images with an octave, for scrolling I had a separate stream that cyclically reduced the speed of scrolling at a given interval and fell asleep until the next task was received. The interactivity was zero.
Now, after some time, I am writing a project, in many ways similar to my first, but at a completely different level of quality and functionality, and again I need a piano. That's what I will talk about.
Component Development
View or SurfaceView?
The rule of thumb that I brought out for myself is to try to use View wherever possible and avoid SurfaceView unless you need to have a component that constantly draws some kind of changing state with less complex graphics (game, video). In all other cases, View is your choice. You also need to consider that using SurfaceView, we lose the ability to animate this component in the future in your layout.
First stage
So, let's get started, the first thing we will do is create a new class, the successor of android.view.View. Let's call it PianoView.
public class PianoView extends View {
public PianoView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
}
As you can see, we have a constructor in which we are passed a context and a set of attributes. In the onDraw method, we will draw our component. This method is called every time when it becomes necessary to redraw the view, for example, for each frame of the animation.
Keyboard rendering. Graphic resources.
To draw keys, I will use standard Android tools: selector, nine-patch drawable.
For white keys, I prepared the following 9-patch images. I decided to make a highlighted state using the standard blue backlight for Holo.
For black:
And for each of them created a selector:
All that’s now left is to get these Drawables in code using
context.getResourses.getDrawable();
Keyboard rendering. The code
So, to keep the component code clean, I took out all the keyboard rendering and storing the necessary information for this in the Keyboard class. In ours,
onDraw
I will just call its method:protected void onDraw(Canvas canvas) {
if (measurementChanged) {
measurementChanged = false;
keyboard.initializeInstrument(getMeasuredHeight(), getContext());
instrumentWidth = keyboard.getWidth();
}
keyboard.draw(canvas);
}
I will not tell you in great detail how the piano is rendered simply because it takes up too much space with boring text and code. Anyone who wants to consider the details can take my code and see. Here I will explain only the principle.
The first step is initialization. Initialization involves calculating an array of keys.
Key[] keysArray;
This is our model. Each item is a key. The key knows its coordinates (in the component coordinate system) and dimensions, whether it is black or white, or 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 occurs with every change in the physical size of our component, our keyboard is initialized (the measurementChanged flag is responsible for this, which we simply set to true in the onMeasure method). Thus, we will not calculate the key positions each time we draw.
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 the required number of times to obtain the remaining octaves. Also, each key will have a MIDI code according to which it would be possible to play sound. Midi codes have end-to-end numbering. Our first key code will be START_MIDI_CODE. The code of any key is calculated by adding the start code and the index of the key in the array.
Next is the rendering of the keys. In a loop over the entire 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);
}
Rendering takes place in 2 stages, because first we need to draw white keys, then black so that there are no overlays. We could have avoided this if the 9-key patches had been made not rectangular, with cutouts. Moreover, this could help us remove the extra redrawn pixels, but for the purposes of this article, let's keep everything as primitive as possible.
Done, our tool is successfully drawn:
Not bad. Of course, when you click on the keys, nothing happens now. Let's fix it.
Key Interaction
For interactions with user clicks, the onTouchEvent method is usually redefined and it determines what the user did - touch a finger, perform a gesture, double touch, long touch, etc. Fortunately, in most cases you and I are free from such troubles.
We will use the GestureDetector class, kindly provided by the platform from its first days.
Let's add a field to our tool
private GestureDetector gestureDetector;
and initialize it.private void init() {
if (!isInEditMode()) {
gestureDetector = new GestureDetector(getContext(), gestureListener);
}
}
We pass listener gestureListener to the constructor, this is the place where we get callbacks from the detector when any gestures are 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 operation algorithm is simple, in the onDown method we pass the coordinates of the keystroke to our keyboard, where we search for the pressed key (the touchItem method calculates its index by the coordinates 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 other methods, we reset the pressed key (when scrolling, finger up, etc.). This is done by analogy, for example, with ListView, when we start scrolling a sheet, the selection is reset.
The next step is to connect the detector to our component. This is done very simply:
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_CANCEL) {
resetTouchFeedback();
}
return super.onTouchEvent(event) || gestureDetector.onTouchEvent(event);
}
Please note that we also check if the action is not
ACTION_CANCEL
, and, in this case, also reset the selection, because the GestureDetector does not react to it in any way, and if it suddenly happened, we risk staying with the forever highlighted key. Check:
Hooray, now it looks a little more alive. But we still see only part of the keyboard ... It doesn’t matter, let's screw the scrolling.
Adding Scrolling to a Component
So, first of all, let's look at how we will shift our content. The easiest way is to not shift anything, but to draw in the same way, but to move the canvas itself. The Canvas class allows you to perform affine transformations on yourself.
Let's add a simple field
private int xOffset;
to our class.
Now we will expand our onDraw method with a construction like this:
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 look at what we did:
- canvas.save () - remembers the current state of the canvas. Creates a kind of breakpoint
- canvas.translate () - shifts the canvas by a specified distance
- canvas.restore () - restore the canvas to its original state.
We also added the updateBounds method to our Keyboard class. It allows you to pass the left and right visible border, so that we do not draw the keys that go beyond the screen. Such an optimization.
Now that we have added support for scrolling during the drawing phase, we will add it to the user interaction - GestureDetector. 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 on our keyboard, it will scroll cute without leaving the keyboard. But this is not enough for us. We want to be able to pull the finger and launch the keyboard inertia - make fling.
Fortunately, we do not have to calculate the speed of the finger and the distance traveled by it on the screen. All this is done for us by our beloved GestureDetector. We just need to override the onFling method. He will help us find out that the user fling, as well as his initial characteristics. But in order to track the state of the scroll, interpolate between the starting and of course points, we need another component - Scroller, or rather, his brother - OverScroller (we want to add glow effects in the future). Scroller is an extremely useful component for any type of scrolling in Android, it is used in countless internal components, and implements standard scrolling behavior.
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 launch the scroller with the initial offset and speed, indicate the minimum and maximum of scrolling to it.
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 each frame of the animation we call scroller.computeScrollOffset (), this method returns true if the scroller is animated, then we get the current value of the xOffset variable.
Since animation involves a series of redraws, at the end of the method we check to see if the scroller is finished animating, and if not, assign the next frame of the animation. Thus, until the scroller finishes work, or is stopped by force, the onDraw method will be called as often as possible and draw your component.
Now our component will scroll nicely and support fling. But something is missing, right? Not enough standard scrollbars below. Not a problem.
Adding standard callbars
Adding standard scrollbars is like a spell, there are no special secrets, just a sequence of actions.
First, we need to tell our component that it supports all the standard scrolling attributes. To do this, create the attrs.xml file in our values directory, to which add the following definition:
Now, add to the constructor:
setVerticalScrollBarEnabled(false);
setHorizontalScrollBarEnabled(true);
TypedArray a = context.obtainStyledAttributes(R.styleable.View);
initializeScrollbars(a);
a.recycle();
The next step is to redefine the three simplest methods in which we will control the sizes and positions of scrollbars:
protected int computeHorizontalScrollExtent() {
return canvasWidth;
}
@Override
protected int computeHorizontalScrollOffset() {
return xOffset;
}
@Override
protected int computeHorizontalScrollRange() {
return instrumentWidth;
}
The code speaks for itself - in the first method we indicate the width of our component, in the second, the current offset of the scrolling, in the third the size of the entire keyboard (which goes beyond the screen). Now it remains to “wake up” these scrollbars when needed. The base View class provides a special awakenScrollBars () method for this. Add the following lines:
if (!awakenScrollBars()) {
invalidate();
}
to the onScroll and onFling methods of our GestureDetectorListener.
The result - standard scrollbars please our eyes.
Conclusion
So, in this part, we looked at creating a component, drawing using Drawables, various drawables states, visual feedback when interacting, scrolling, fling gesture, creating scrollbars.
The article was long enough, so I decided to break it into several parts.
In the next part I will talk about:
- Saving component state when rotating the screen
- adding backlight for overscroll
- passing parameters in XML
- pinch zoom
I also have plans for the third part, where I will talk about optimization, about the difference between using ready-made bitmaps and drawing on the canvas (drawCircle, drawText, etc), about getting rid of redrawing, etc. I’ll write the third article only if the first readers will like the two and there will be interest in the appearance of the third :) The
sources of the finished project for this series of articles are on my github at goo.gl/VDeuw . I want to note right away that these are clippings from the project under development, and if you find some code that, it would seem, isn’t needed, I quite possibly didn’t notice it and forgot to cut it out.