Musical Instrument Digital Interface (MIDI) has been around since the early 1980’s and the basic specification has changed little since. It is a standard by which electronic musical instruments and other devices can communicate with each other. In Marshmallow (V6.0 – API 23) Android actually got some good MIDI support, and in this series of articles we’ll take a look at how we can create a MIDI controller app. For the non-musicians and those who have no interest in MIDI, do not despair there will be some custom controls we create along the way which may still be of interest. In this article we’ll take a look at the custom controls that we’ll use for the UI.
Previously we looked at how we can discover available MIDI devices and display them in a list to the user but before we can start sending MIDI events to one of those devices we’ll need a method for the user to trigger these events. The UI for MidiPad consists of twelve distinct pads which the user can tap to generate MIDI events, so let’s take a look at how we create these controls.
We’ll start with a custom ViewGroup named MidiPad which will encapsulate the behaviour of all twelve pads in a single control that we can just drop in to our main layout:
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="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="com.stylingandroid.midipad.MainActivity"> <android.support.v7.widget.Toolbar android:id="@+id/main_toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" android:elevation="4dp" android:theme="@style/ThemeOverlay.AppCompat.ActionBar" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:popupTheme="@style/ThemeOverlay.AppCompat.Light" /> <com.stylingandroid.midipad.ui.MidiPad android:id="@+id/midi_pad" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/main_toolbar" /> </android.support.constraint.ConstraintLayout>
The code for this is pretty straightforward:
class MidiPad @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0 ) : ConstraintLayout(context, attrs, defStyle) { init { View.inflate(context, R.layout.midi_pad, this) } override fun onFinishInflate() = super.onFinishInflate().run { filter { it is PadView }.forEachIndexed { index, view -> view.setOnTouchListener { _, motionEvent -> touch(NOTES[index], motionEvent) } } } private fun filter(predicate: (View) -> Boolean): List<View> = ArrayList<View>().apply { for (index in 0 until childCount) { getChildAt(index)?.takeIf(predicate)?.also { add(it) } } } private fun touch(note: Int, motionEvent: MotionEvent): Boolean = when (motionEvent.action) { MotionEvent.ACTION_DOWN -> { println("Note On: $note") //TODO start note playing false } MotionEvent.ACTION_UP -> { println("Note Off: $note") //TODO stop note playing false } else -> false } companion object { private const val START_NOTE = 44 private const val END_NOTE = 55 private val NOTES = (START_NOTE..END_NOTE).toList() } }
In the init()
method we inflate a child layout – more on this in a moment.
In the onFinishInflate()
function we attach an OnTouchListener to each of the PadView widgets in the layout. An interesting trick we can use here is to filter the child views so that we obtain a list which is only the PadView instances. In actual fact, there are only PadView children, but this is a useful trick which protects things if we were to add additional Views which are not PadView to our child layout. For each of these views we add an OnTouchListener which will call the touch() function with a different note number. Each pad will get a different note number corresponding to the MIDI note that it will trigger.
The filter()
function is the workhorse behind this technique. It creates an ArrayList, then iterates through the child views add only adds those for which the predicate
lambda evaluates to true. This list is returned.
The touch()
function does not do much yet. Eventually it will trigger the MIDI NoteOn & NoteOff events. For now we just print out the note number.
Finally we have the companion object which contains the constants. NOTES is an array of number corresponding to the MIDI note numbers for each of the pads. In this case the range will be notes 44 (G#3) to 55 (G4).
The child layout contains the twelve PadView controls which make up the set of pads:
<merge xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="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="com.stylingandroid.midipad.MainActivity" tools:parentTag="com.stylingandroid.midipad.ui.MidiPad"> <com.stylingandroid.midipad.ui.PadView android:id="@+id/pad1" style="@style/Pad" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginEnd="8dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" app:layout_constraintBottom_toTopOf="@+id/pad3" app:layout_constraintEnd_toStartOf="@+id/pad2" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <com.stylingandroid.midipad.ui.PadView android:id="@+id/pad2" style="@style/Pad" android:layout_width="0dp" android:layout_height="0dp" android:layout_marginEnd="16dp" android:layout_marginStart="8dp" app:layout_constraintBottom_toBottomOf="@id/pad1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/pad1" app:layout_constraintTop_toTopOf="@+id/pad1" /> . . . </merge>
The important thing here is the use of <merge>
as the top level container which will add these as direct children of MidiPad which extends ConstraintLayout.
There’s nothing particularly complex thus far, but PadView will require a little more explanation. Firstly we have a number of custom attributes:
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="PadView"> <attr name="padColour" format="color|reference" /> <attr name="outlineColour" format="color|reference" /> <attr name="outlineWidth" format="dimension|reference" /> <attr name="cornerRadius" format="float|reference" /> <attr name="fadeInDuration" format="integer|reference" /> <attr name="fadeOutDuration" format="integer|reference" /> </declare-styleable> </resources>
These allow us to control the colour of both the pad fill colour and the pad outline, as well as the outline width and the radius of the rounded corners. Also we can specify the duration of the animations which will run when we tap on a pad, and then release.
We specify these in a style which is applied to each of the PadView instances in the layout:
<resources xmlns:tools="http://schemas.android.com/tools"> <!-- Base application theme. --> <style name="AppTheme" parent="Theme.AppCompat.NoActionBar"> <!-- Customize your theme here. --> <item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimaryDark">@color/colorPrimaryDark</item> <item name="colorAccent">@color/colorAccent</item> </style> <style name="Pad" tools:ignore="UnusedResources"> <item name="padColour">@color/colorAccent</item> <item name="outlineColour">@color/colorPrimaryDark</item> <item name="outlineWidth">3dp</item> <item name="cornerRadius">20</item> <item name="fadeInDuration">100</item> <item name="fadeOutDuration">@android:integer/config_shortAnimTime</item> </style> </resources>
Now on to PadView itself:
class PadView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, private val bounds: RectF = RectF(), private val outlinePath: Path = Path() ) : View(context, attrs, defStyleAttr) { private var padColour: Int = 0 private var outlineColour: Int = 0 private var outlineWidth: Float = 1f private var cornerRadius: Float = 0f private var fadeInDuration: Long = 0 private var fadeOutDuration: Long = 0 private var animator: Animator? = null private var pressure: Float = 1f set(value) { field = value invalidate() } private val outline: Paint by lazyFast { Paint().apply { color = outlineColour strokeWidth = outlineWidth style = Paint.Style.STROKE isAntiAlias = true } } private val fill: Paint by lazyFast { Paint().apply { color = padColour style = Paint.Style.FILL } } init { attrs?.apply { context.obtainStyledAttributes(this, R.styleable.PadView).apply { val defaultColour = context.theme.getColour(R.attr.colorAccent) padColour = getColor(R.styleable.PadView_padColour, defaultColour) outlineColour = getColor(R.styleable.PadView_outlineColour, defaultColour) outlineWidth = getDimension(R.styleable.PadView_outlineWidth, 1f) cornerRadius = getFloat(R.styleable.PadView_cornerRadius, 0f) fadeInDuration = getLong(R.styleable.PadView_fadeInDuration, 0) fadeOutDuration = getLong(R.styleable.PadView_fadeOutDuration, 0) recycle() } } } private fun TypedArray.getLong(index: Int, default: Int) = getInt(index, default).toLong() . . . }
Much of this is initialising variables from our custom attributes. We also have a variable to hold a pressure
value which will control how the control is rendered. It is this value that we’ll animate later on, so whenever this changes we’ll need to redraw the control hence the need to override the setter, and call invalidate whenever the pressure value changes.
New we have some lazy initialisation for two Paint objects, one for the outline and the other for the fill of the pad. They are instantiated lazily because we do not have the variables for the custom attributes populated until after init has been called.
The glow effect for the fill is a function of the size of the control, so we need to re-create this and the path to draw the outline whenever the size of the control changes:
override fun onSizeChanged(newWidth: Int, newHeight: Int, oldWidth: Int, oldHeight: Int) = super.onSizeChanged(newWidth, newHeight, oldWidth, oldHeight).run { adjustBounds(newWidth.toFloat(), newHeight.toFloat()) } private fun adjustBounds(width: Float, height: Float) { bounds.set(0f, 0f, width, height) outlinePath.apply { reset() addRoundRect(bounds, cornerRadius, cornerRadius, Path.Direction.CW) } fill.shader = RadialGradient( width / 2, height / 2, Math.max(width, height) * SCALE_FACTOR, padColour, Color.TRANSPARENT, Shader.TileMode.CLAMP ) setLayerType(View.LAYER_TYPE_SOFTWARE, fill) }
We store the bounds of the View in a RectF named bounds
which we’ll need when we come to draw the control.
Next we create the path to draw the outline. We could manually draw this in onDraw()
but we also want to prevent anything from being drawn outside of the rounded corners, and a Path will enable us to do this – more on this when we look at onDraw()
.
The fill is a RadialGradient Shader. It is centred at the centre of the View, and has a radius of either the width or height – whichever is the greater – multiplied by SCALE_FACTOR
. I played with different values of SCALE_FACTOR
until I found one I liked – 0.6. Next we have the start and end colours for the gradient. The middle will be padColour
(one of our custom attributes), and it will fade to transparency at the edge. The final argument is the tile mode – in this case we want the shader to render only once – around the centre point so we specify CLAMP
mode.
The final thing we do is specify the layer type which governs how the layer will be rendered. I have gone for software rendering, purely because I preferred the way it looked. Given more time, I would probably define a more detailed gradient with a number of stops to gain fine control over how the gradient rendered, and use a hardware layer for performance reasons. But for now this looks and performs well enough.
Next lets take a look at onDraw()
:
override fun onDraw(canvas: Canvas?) { canvas?.apply { super.onDraw(this) drawPath(outlinePath, outline) save().also { clipPath(outlinePath) translate(-pressureOffset(bounds.width()), -pressureOffset(bounds.height())) scale(pressure, pressure) drawRect(bounds, fill) restoreToCount(it) } } } private fun pressureOffset(dimension: Float) = ((pressure * dimension) - dimension) / 2
First we draw the outline. Because we defined a path for this, we can actually render it with a single drawPath()
call.
Newt we render the fill gradient, but we get a little sneaky. We actually want the gradient size to be dependent on the pressure value. Rather than recreate the shader for each frame, we can render it with a transformation applied to the canvas. To so this we must first save the current state of the canvas, as it is important to leave the canvas in the same state as we started.
Next we apply a clipPath() to the path that we created earlier. This will prevent the gradient from being drawn outside of this path. This is particularly important because we have rounded corners. If we just constrained the fill to the bounds of the view, we would see it rendering outside of the rounded corners of the outline. However by clipping to the same path as the outline we prevent this from happening.
We are about to scale the canvas to change how the gradient is rendered, it means that the physical size that the gradient will be drawn at will change according to the value of pressure
. To keep it centred, we must first apply an offset to keep the gradient centred. This is done by the translate()
call which will maintain the centre point of the gradient.
Next we apply the scale based on the pressure.
Now we can draw the gradient.
Finally we reset the canvas state back to the saved state, thus removing the clip path, translation, and scale that were applied since the save.
Now we add some touch handling to trigger the animation when the user taps and releases:
override fun onTouchEvent(event: MotionEvent?): Boolean = event?.action?.let { when (it) { MotionEvent.ACTION_DOWN -> { animatePressure(1f + event.pressure, fadeInDuration) performClick() true } MotionEvent.ACTION_UP -> { animatePressure(1f, fadeOutDuration) performClick() true } else -> super.onTouchEvent(event) } } ?: super.onTouchEvent(event) override fun performClick(): Boolean { super.performClick() return true }
When we receive an ACTION_DOWN event, we calculate a new target pressure value based on the pressure of the touch event. We then create an ObjectAnimator to animate the pressure of the current view to that target value. When we receive an ACTION_UP event, we create an ObjectAnimator to animate the pressure of the current view back to 1.
The final piece of the puzzle is the ObjectAnimator creation:
private fun animatePressure(newPressure: Float, duration: Long) { animator?.takeIf { it.isRunning }?.cancel() with(ObjectAnimator.ofFloat(this, PRESSURE, pressure, newPressure)) { animator = this this.duration = duration start() } }
We first check for any running Animator and cancel it if there is one. Then we create an ObjectAnimator which will animate the pressure value from it’s current value to the value supplied in the newPressure
argument. Because of how we defined the setter from pressure to trigger a redraw of the control then just running this animator gives precisely the behaviour that we need:
So we now have a list of available MIDI devices, and the necessary controls to generate MIDI events so, in the final article in this series, we’ll tie the two together and generate MIDI events when the user taps on the pads.
The source code for this article is available here.
© 2017, Mark Allison. All rights reserved.
Copyright © 2017 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.