⬆️ ⬇️

Creating a custom component from scratch. Part 2

Introduction





Greetings again, colleagues.



In my previous article, I talked about the basics of creating a custom component using the example of a simple but nice piano keyboard.

')





In this article, under the cut, we will continue to wind the whistles and ... additional features of our keyboard. On the agenda:



  1. Preserving component state when rotating screen
  2. adding backlight in overscroll
  3. XML parameter passing
  4. Multitouch Zoom




Preserving component state when rotating screen



Now we can detect this behavior in our component. If we scroll to any position, then rotate the screen, the scroll will be at zero. Obviously, this is because when the screen is rotated, the Activity is recreated, respectively, the View is also recreated.

The first thing that comes to mind is to use the onSaveInstanceState() method of our activation, pull the value of the scroll from the component and save, and later, when you re-create, set the scroll to our component. And it will work, but it can hardly be called the right approach. Imagine that we have not one parameter to be saved, but ten, or not one component, but ten ... with ten parameters.

Fortunately, the internal mechanisms of Android already provide for the automatic saving of the state of all components that have an identifier. After all, you don’t need to do anything to keep the Listroll scrolling around when you turn, right? So we will take advantage of what is already in View and will manage the preservation of the state of the component from the inside, not from the outside.

And this is done surprisingly simple. We need to override the methods of the View onSaveInstanceState() class and onRestoreInstanceState(Parcelable state) . However, there is a small difference from the analogues in the activation. There we deal with the Bundle , here we have Parcelable. We need to make our own Parcelable class, which must be derived from android.view.View.BaseSavedState .



 public static class SavedState extends BaseSavedState { int xOffset; int instrumentWidth; //          :) SavedState(Parcelable superState) { super(superState); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeInt(xOffset); out.writeInt(instrumentWidth); } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; private SavedState(Parcel in) { super(in); xOffset = in.readInt(); instrumentWidth = in.readInt(); } } 




This is how it looks in our case. Now it only remains to use it:



 @Override protected Parcelable onSaveInstanceState() { SavedState st = new SavedState(super.onSaveInstanceState()); st.xOffset = xOffset; st.instrumentWidth = xOffset; return st; } protected void onRestoreInstanceState(Parcelable state) { if (!(state instanceof SavedState)) { super.onRestoreInstanceState(state); return; } SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); xOffset = ss.xOffset; xOffset = ss.instrumentWidth; }; 




Is done. If you now rotate the screen, the scroll will not be lost. But there is another little cosmetic detail that I would add. In our component, when turning, there is a high probability that the width of the keyboard will change, as we change its height (less in landscape mode), the keys will become narrower or wider. Therefore, our static xOffset value loaded after re-creation needs to be adjusted. This is done very simply. First, we will keep the old width of our keyboard when recreating. That is why in the code above I also save the instrumentWidth field in our SavedState.

In our onDraw (), where we initialize the component after resizing it, we will add the following modifications:



 if (measurementChanged) { measurementChanged = false; keyboard.initializeInstrument(getMeasuredHeight(), getContext()); float oldInstrumentWidth = instrumentWidth; instrumentWidth = keyboard.getWidth(); float ratio = (float) instrumentWidth / oldInstrumentWidth; //          xOffset = (int) (xOffset * ratio); } 




Now if our scroll before the turn was, for example, at the beginning of the second octave, it will remain there after the turn.

So, we successfully save the state of our component when turning. Now let's add another visual beauty, namely the effect of glow on the sides when the scroll reaches the end of the tool.



Glow effect for overscroll



As one would expect, a ready-made component has already been made for us, which is able to draw these very edges correctly, but we need to insert it correctly and voila. This component is called EdgeEffect . But we will not use it, since it appeared only in ICS. We will use the EdgeEffectCompat class, which is accessible by the compatibility library and is a wrapper over EdgeEffect. Unfortunately, this means that in versions where the effect is not supported, this class will serve as a simple stub and nothing will happen.

So, we need two copies - for the left and right edges.



 private EdgeEffectCompat leftEdgeEffect; private EdgeEffectCompat rightEdgeEffect; 




They are initialized in a simple way in the activity.



Now, draw. Like the scrollbars, the effect is drawn on top of all the content, so it makes sense to put it in the draw () method. Here I honestly admit that what goes further is done by analogy with the way it is implemented in the ViewPager class. In general, we can draw an effect with on exactly the same result in onDraw, but, in general, this is in my opinion even more beautiful, because in onDraw we draw our own, in draw - system effects.



 public void draw(Canvas canvas) { super.draw(canvas); boolean needsInvalidate = false; final int overScrollMode = ViewCompat.getOverScrollMode(this); if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS || (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS)) { if (!leftEdgeEffect.isFinished()) { final int restoreCount = canvas.save(); final int height = getHeight() - getPaddingTop() - getPaddingBottom(); final int width = getWidth(); canvas.rotate(270); canvas.translate(-height + getPaddingTop(), 0); leftEdgeEffect.setSize(height, width); needsInvalidate |= leftEdgeEffect.draw(canvas); canvas.restoreToCount(restoreCount); } if (!rightEdgeEffect.isFinished()) { final int restoreCount = canvas.save(); final int width = getWidth(); final int height = getHeight() - getPaddingTop() - getPaddingBottom(); canvas.rotate(90); canvas.translate(-getPaddingTop(), -width); rightEdgeEffect.setSize(height, width); needsInvalidate |= rightEdgeEffect.draw(canvas); canvas.restoreToCount(restoreCount); } } else { leftEdgeEffect.finish(); rightEdgeEffect.finish(); } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this); } } 




So what happens here. First, we check if our component supports an overscroll, and, if so, draw both effects in succession. The fact is that EdgeEffect does not support the direction in which it is drawn, so in order to correctly display the effect on the left or right, we need to rotate our canvas in the right way.



 if (!leftEdgeEffect.isFinished()) { final int restoreCount = canvas.save(); final int height = getHeight() - getPaddingTop() - getPaddingBottom(); final int width = getWidth(); canvas.rotate(270); canvas.translate(-height + getPaddingTop(), 0); leftEdgeEffect.setSize(height, width); needsInvalidate |= leftEdgeEffect.draw(canvas); canvas.restoreToCount(restoreCount); } 




Here we are consistently:

  1. Save the canvas using canvas.save ()
  2. we calculate its height minus paddings and set the size of the effect using the leftEdgeEffect.setSize (height, width) method;
  3. Rotate the canvas 270 degrees and position it correctly.




I want to present it more clearly. Let's remove the canvas transformations:







This is what the default effect looks like. Always down. If we add only a rotation of 270, we will see that the effect is drawn in the right direction, but at the very top corner of the canvas.







And only after adding the displacement of the canvas, we see that the effect is in place.







But I ran ahead, because while we have the effect, though drawn, but it does not activate when scrolling.

Here we need to go back to our gesture detector and modify onScroll.



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




First, we stopped limiting xOffset to bounds, as we did before, plus we call the onPull method for the corresponding effect.

It is important to note here that since we stopped limiting the xOffset variable here, we need to do it in other places where it can cause an error, for example in the onDraw method and computeHorizontalScrollOffset (). Perhaps there is a more beautiful way to do this, but he has not yet come to my mind.

The final touch we want to add is the scroll speed absorption when our edge reaches the edge. To do this, add the following code to our onDraw :



 if (scroller.isOverScrolled()) { if (xOffset < 0) { leftEdgeEffect.onAbsorb(getCurrentVelocity()); } else { rightEdgeEffect.onAbsorb(getCurrentVelocity()); } } // ... @TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private int getCurrentVelocity() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { return (int) scroller.getCurrVelocity(); } return 0; } 




Unfortunately, the Scroller.getCurrVelocity() method is available to us only starting from ICS, so I marked the method as aimed at API 14+. Yes, this is far from ideal, but, again, this is what we have.

Now, when trying to scroll beyond View, we get a beautiful glow in the style of Holo.



Add component parameters to XML





Before proceeding directly to adding parameters, I will add a small feature to our component. Suppose that when you click on the key, we will see a circle with the name of this note.

This is done trivially. I started an array of Note objects.



 private ArrayList<Note> notesToDraw = new ArrayList<Note>(); 




and each time I click on the key, I define a note by the midi code of the key and add it to the array. In detail how this happens can be seen in the code on the githaba.



Now in the Keyboard class, I add the method drawOverlays (ArrayList)



 public void drawOverlays(ArrayList<Note> notes, Canvas canvas) { int firstVisibleKey = getFirstVisibleKey(); int lastVisibleKey = getLastVisibleKey(); for (Note note : notes) { int midiCode = note.getMidiCode(); if (midiCode >= firstVisibleKey && midiCode <= lastVisibleKey) { drawNoteFromMidi(canvas, note, midiCode, false); } } } private void drawNoteFromMidi(Canvas canvas, Note note, int midiCode, boolean replica) { Key key = keysArray[midiCode - Keyboard.START_MIDI_CODE]; overlayTextPaint.setColor(circleColor); canvas.drawCircle(key.getOverlayPivotX(), key.getOverlayPivotY(), overlayCircleRadius, overlayTextPaint); String name = note.toString(); overlayTextPaint.getTextBounds(name, 0, name.length(), bounds); int width = bounds.right - bounds.left; int height = bounds.bottom - bounds.top; overlayTextPaint.setColor(Color.BLACK); canvas.drawText(name, key.getOverlayPivotX() - width / 2, key.getOverlayPivotY() + height / 2, overlayTextPaint); } 




... and draw a note as a circle and text. As you probably already guessed, I did it so that we could customize the parameters of this circle and text via XML.



Let's customize the color, circle radius, and text size in the XML attributes of our View. First you need to announce them. The <declare-styleable> tag is used for this.



 <declare-styleable name="PianoView"> <attr name="overlay_color" format="color"></attr> <attr name="overlay_circle_radius" format="dimension"></attr> <attr name="overlay_circle_text_size" format="dimension"></attr> </declare-styleable> 




add this definition to attrs.xml. Now, we need to load them in our component. In the constructor, add the following code



 TypedArray pianoAttrs = context.obtainStyledAttributes(attrs, R.styleable.PianoView); int circleColor; float circleRadius; float circleTextSize; try { circleColor = pianoAttrs.getColor(R.styleable.PianoView_overlay_color, Color.GREEN); circleRadius = pianoAttrs.getDimension(R.styleable.PianoView_overlay_circle_radius, TypedValue .applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24, context.getResources().getDisplayMetrics())); circleTextSize = pianoAttrs.getDimension(R.styleable.PianoView_overlay_circle_text_size, TypedValue .applyDimension(TypedValue.COMPLEX_UNIT_SP, 12, context.getResources().getDisplayMetrics())); } finally { pianoAttrs.recycle(); } 




using the getXXX method, we get the value of the attribute type XXX. If the attribute is missing, the second argument specifies the default value.



It remains now to indicate them in our markup. To do this, you first need to declare a namespace in the header: xmlns:piano="http://schemas.android.com/apk/res-auto" , after which we get the following markup file:



 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:piano="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".PianoDemoActivity" > <com.evilduck.piano.views.instrument.PianoView android:id="@+id/instrument_view" android:layout_width="match_parent" android:layout_height="300dip" piano:overlay_circle_radius="18dip" piano:overlay_circle_text_size="18sp" piano:overlay_color="#00FF00" /> </RelativeLayout> 




In this way, we can make our components as flexible as the standard components of the platform.



Multi-touch support



The last thing I wanted to talk about today is basic zoom support with a multitouch gesture.

To create the zoom effect, we will use the ScaleGestureDetector component. It is absolutely analogous to the GestureDetector in terms of use in the code, it differs, only the listener passed to it:



 private OnScaleGestureListener scaleGestureListener = new OnScaleGestureListener() { @Override public void onScaleEnd(ScaleGestureDetector detector) { } @Override public boolean onScaleBegin(ScaleGestureDetector detector) { return true; } @Override public boolean onScale(ScaleGestureDetector detector) { scaleX *= detector.getScaleFactor(); if (scaleX < 1) { scaleX = 1; } if (scaleX > 2) { scaleX = 2; } ViewCompat.postInvalidateOnAnimation(PianoView.this); return true; } }; 




we introduced the scaleX variable, which will express the level of our zoom and limit it to 1 and 2.

Another question is how we will zoom our keyboard. For this article, I chose the simplest option - just convert the canvas. Yes, it is not perfect, and will lead to a distortion of the picture. That's right - based on the scaleX value, increase the width of the keys, the radius of the circles and the text. This is specific to my task and is not related to the zoom as a whole. Therefore, we simply scale the canvas:



 canvas.save(); //    canvas.scale(scaleX, 1.0f); canvas.translate(-localXOffset, 0); keyboard.updateBounds(localXOffset, canvasWidth + localXOffset); keyboard.draw(canvas); if (!notesToDraw.isEmpty()) { keyboard.drawOverlays(notesToDraw, canvas); } canvas.restore(); 




Done, if you make a pushing gesture with your fingers, we will see how the keyboard grows wide:







Conclusion



This is the end of the second part of my series of articles. I hope that this will help someone quickly understand the intricacies of creating custom components and improve the quality of their projects.

The finished example is still available on my github: goo.gl/VDeuw .

Also, I strongly recommend that you read this article from the official documentation:

developer.android.com/training/gestures/index.html

In the third article I will try to highlight the issues of optimization, the use of bitmaps instead of programmatically drawing text, circles, consider what pixel redrawing occurs in our component, and how you can get rid of them.

Source: https://habr.com/ru/post/176919/



All Articles