Hvordan jeg byggede en Android drejeknap med Kotlin til at hjælpe min søn med at øve klaver

Da min søns klaverlærer fortalte ham, at han skulle bruge en metronom til at øve timing, tog jeg det som en mulighed for at lære Kotlin. Jeg besluttede at lære sproget og Androids økosystem, så jeg kunne oprette en Metronome-app.

Min oprindelige implementering brugte en SeekBar til at kontrollere BPM (Beats per Minute) - den hastighed, hvormed metronomen kryds.

Efterhånden som projektet skred frem, ønskede jeg dog at få det til at ligne en fysisk digital enhed, som den blev brugt af mange musikere i den virkelige fysiske verden.

Fysiske enheder har ikke en "SeekBar View", og jeg ville efterligne drejeknappen, som en faktisk enhed muligvis har.

Drejeknapper er meget nyttige UI-kontroller. De ligner meget en skyder eller SeekBar, der kan bruges i mange situationer. Her er nogle af deres fordele:

  • De bruger meget lidt fast ejendom i din app
  • De kan bruges til at kontrollere kontinuerlige eller diskrete værdiområder
  • De genkendes straks af brugere fra applikationer i den virkelige verden
  • De er ikke standard Android-kontroller og giver således en unik “brugerdefineret” følelse på din app

Mens der findes et par open source-knapbiblioteker til Android, fandt jeg ikke helt det, jeg ledte efter, i nogen af ​​dem.

Mange var overkillede til mine beskedne behov med funktionalitet som at indstille baggrundsbilleder eller håndtere vandhaner til to eller flere tilstandsoperationer osv.

Nogle havde ikke den tilpasningsevne, jeg ønskede at passe til mit projekt, og de kom med deres eget knapbillede.

Atter andre antog et diskret område af værdier eller positioner. Og mange af dem syntes meget mere komplekse end nødvendigt.

Så jeg besluttede at designe en selv - hvilket blev til et sjovt lille projekt i sig selv.

I denne artikel vil jeg diskutere, hvordan jeg byggede den.

Så lad os se, hvordan vi kan skabe en drejeknap.

Design af en knap

Det første skridt var at oprette grafikken til selve knappen. Jeg er på ingen måde designer, men det faldt mig ind, at nøglen til at skabe en følelse af "dybde" og bevægelse i en drejeknap ville være at bruge en off-center radial gradient. Dette ville give mig mulighed for at skabe en illusion af en deprimeret overflade og lysreflektion.

Jeg brugte Sketch til at tegne knappen og eksporterede den derefter til svg. Derefter importerede jeg det tilbage til Android-studio som en tegning.

Du kan finde knappen, der kan trækkes i GitHub-projektlinket nederst i denne artikel.

Oprettelse af visningen i xml

Det første trin i oprettelsen af ​​visningen er at oprette en layout xml-fil i mappen res / layout.

Visningen kan oprettes fuldstændigt i kode, men en god genanvendelig visning i Android skal oprettes i xml.

Bemærk mærket - vi bruger det, da vi udvider en eksisterende Android Layout-klasse, og dette layout vil være den indre struktur af dette layout.

Vi bruger en ImageView til knappen, som vi roterer, når brugeren bevæger den.

For at gøre knappen konfigurerbar med xml opretter vi attributter for det værdiområde, som knappen returnerer, såvel som for den tegneserie, den bruger til visuals.

Vi opretter en attrs.xml-fil under res / værdier.

Opret derefter en ny Kotlin-klassefil, RotaryKnobView, der udvider RelativeLayout og implementerer grænsefladen GestureDetector.OnGestureListener.

Vi bruger RelativeLayout som overordnet container til styringen og implementerer OnGestureListener til at håndtere knapens bevægelsesbevægelser.

@JvmOverloads er kun en genvej til at tilsidesætte alle tre varianter af View-konstruktøren.

Derefter initialiserer vi nogle standardværdier og definerer klassemedlemmer.

class RotaryKnobView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : RelativeLayout(context, attrs, defStyleAttr), GestureDetector.OnGestureListener { private val gestureDetector: GestureDetectorCompat private var maxValue = 99 private var minValue = 0 var listener: RotaryKnobListener? = null var value = 50 private var knobDrawable: Drawable? = null private var divider = 300f / (maxValue - minValue)

En note om skillelinjen - Jeg ville have, at knappen havde start- og slutposition, snarere end at kunne rotere på ubestemt tid, ligesom en lydstyrkeknap på et stereoanlæg. Jeg satte start- og slutpunkterne på henholdsvis -150 og 150 grader. Så den effektive bevægelse til knappen er kun 300 grader.

Vi bruger skillelinjen til at distribuere rækkevidden af ​​værdier, som vi ønsker, at vores knap skal vende tilbage på disse tilgængelige 300 grader - så vi kan beregne den aktuelle værdi baseret på knapens positionsvinkel.

Dernæst initialiserer vi komponenten:

  • Oppust layoutet.
  • Læs attributterne i variabler.
  • Opdater skillelinjen (for at understøtte de beståede i minimums- og maksimumværdier.
  • Indstil billedet.
 init { this.maxValue = maxValue + 1 LayoutInflater.from(context) .inflate(R.layout.rotary_knob_view, this, true) context.theme.obtainStyledAttributes( attrs, R.styleable.RotaryKnobView, 0, 0 ).apply { try { minValue = getInt(R.styleable.RotaryKnobView_minValue, 0) maxValue = getInt(R.styleable.RotaryKnobView_maxValue, 100) + 1 divider = 300f / (maxValue - minValue) value = getInt(R.styleable.RotaryKnobView_initialValue, 50) knobDrawable = getDrawable(R.styleable.RotaryKnobView_knobDrawable) knobImageView.setImageDrawable(knobDrawable) } finally { recycle() } } gestureDetector = GestureDetectorCompat(context, this) }

Klassen vil ikke kompilere endnu, da vi har brug for at implementere OnGestureListeners funktioner. Lad os håndtere det nu.

Registrering af brugerbevægelser

OnGestureListener-grænsefladen kræver, at vi implementerer seks funktioner:

onScroll, onTouchEvent, onDown, onSingleTapUp, onFling, onLongPress, onShowPress.

Af disse er vi nødt til at forbruge (returnere sandt) på onDown og onTouchEvent og implementere bevægelses-login i onScroll.

 override fun onTouchEvent(event: MotionEvent): Boolean { return if (gestureDetector.onTouchEvent(event)) true else super.onTouchEvent(event) } override fun onDown(event: MotionEvent): Boolean { return true } override fun onSingleTapUp(e: MotionEvent): Boolean { return false } override fun onFling(arg0: MotionEvent, arg1: MotionEvent, arg2: Float, arg3: Float) : Boolean { return false } override fun onLongPress(e: MotionEvent) {} override fun onShowPress(e: MotionEvent) {}

Her er implementeringen af ​​onScroll. Vi udfylder de manglende dele i det følgende afsnit.

 override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float) : Boolean { val rotationDegrees = calculateAngle(e2.x, e2.y) // use only -150 to 150 range (knob min/max points if (rotationDegrees >= -150 && rotationDegrees <= 150) { setKnobPosition(rotationDegrees) // Calculate rotary value // The range is the 300 degrees between -150 and 150, so we'll add 150 to adjust the // range to 0 - 300 val valueRangeDegrees = rotationDegrees + 150 value = ((valueRangeDegrees / divider) + minValue).toInt() if (listener != null) listener!!.onRotate(value) } return true }

onScroll modtager to koordinatsæt, e1 og e2, der repræsenterer start- og slutbevægelser for den rulle, der udløste begivenheden.

Vi er kun interesseret i e2 - den nye position for knappen - så vi kan animere den til at placere og beregne værdien.

Jeg bruger en funktion, vi gennemgår i det næste afsnit for at beregne rotationsvinklen.

As mentioned earlier, we’re only using 300 degrees from the knob's start point to its end point, so here we also calculate what value the knob’s position should represent using the divider.

Calculating the rotation angle

Now let’s write the calculateAngle function.

 private fun calculateAngle(x: Float, y: Float): Float { val px = (x / width.toFloat()) - 0.5 val py = ( 1 - y / height.toFloat()) - 0.5 var angle = -(Math.toDegrees(atan2(py, px))) .toFloat() + 90 if (angle > 180) angle -= 360 return angle }

This function calls for a bit of explanation and some 8th grade math.

The purpose of this function is to calculate the position of the knob in angles, based on the passed coordinates.

I opted to treat the 12 o’clock position of the knob as zero, and then increase its position to positive degrees when turning clockwise, and reducing to negative degrees when turning counterclockwise from 12 o’clock.

We get the x, y coordinates from the onScroll function, indicating the position within the view where the movement ended (for that event).

X and y represent a point on a cartesian coordinate system. We can convert that point representation to a polar coordinate system, representing the point by the angle above or below the x axis and the distance of the point from the pole.

Converting between the two coordinate systems can be done with the atan2 function. Luckily for us, the Kotlin math library provides us with an implementation of atan2, as do most Math libraries.

We do, however, need to account for a few differences between our knob model and the naïve math implementation.

  1. The (0,0) coordinates represent the top right corner of the view and not the middle. And while the x coordinate progresses in the right direction — growing as we move to the right — the y coordinate is backwards — 0 is the top of the view, while the value of our view’s height is the lowest pixel line in the view.

    To accommodate that we divide x and y by the respective width and height of the view to get them on a normalized scale of 0–1.

    Then we subtract 0.5 from both to move the 0,0 point to the middle.

    And lastly, we subtract y’s value from 1 to reverse its direction.

  2. The polar coordinate system is in reverse direction to what we need. The degrees value rises as we turn counter clockwise. So we add a minus sign to reverse the result of the atan2 function.
  3. We want the 0 degrees value to point north, otherwise passing 9 o’clock, the value will jump from 0 to 359.

    So we add 90 to the result, taking care to reduce the value by 360 once the angle is larger than 180 (so we get a -180 < angle < 180 range rather than a 0 < x < 360 range)

The next step is to animate the rotation of the knob. We'll use Matrix to transform the coordinates of the ImageView.

We just need to pay attention to dividing the view’s height and width by 2 so the rotation axis is the middle of the knob.

 private fun setKnobPosition(angle: Float) { val matrix = Matrix() knobImageView.scaleType = ScaleType.MATRIX matrix.postRotate(angle, width.toFloat() / 2, height.toFloat() / 2) knobImageView.imageMatrix = matrix }

And last but not least, let’s expose an interface for the consuming Activity or Fragment to listen to rotation events:

 interface RotaryKnobListener { fun onRotate(value: Int) }

Using the knob

Now, let’s create a simple implementation to test our knob.

In the main activity, let's create a TextView and drag a view from the containers list. When presented with the view selection, select RotaryKnobView.

Edit the activity’s layout xml file, and set the minimum, maximum, and initial values as well as the drawable to use.

Finally, in our MainActivity class, inflate the layout and implement the RotaryKnobListener interface to update the value of the TextField.

package geva.oren.rotaryknobdemo import androidx.appcompat.app.AppCompatActivity import android.os.Bundle import kotlinx.android.synthetic.main.activity_main.* class MainActivity : AppCompatActivity(), RotaryKnobView.RotaryKnobListener { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) knob.listener = this textView.text = knob.value.toString() } override fun onRotate(value: Int) { textView.text = value.toString() } }

And we're done! This example project is available on github as well as the original metronome project.

Android Metronome-appen er også tilgængelig i Googles playbutik.