Hogyan építettem egy Android forgatógombot Kotlinnal, hogy segítsen a fiamnak a zongorázásban

Amikor a fiam zongoratanára azt mondta neki, hogy metronómot kell használnia az időzítés gyakorlásához, akkor lehetőséget vettem arra, hogy megtanuljam Kotlint. Úgy döntöttem, hogy megtanulom a nyelvet és az Android ökoszisztémáját, hogy elkészíthessem a Metronome alkalmazást.

Első megvalósításom egy SeekBar segítségével szabályozta a BPM-t (Beats per Minute) - a metronóm ketyegésének sebességét.

A projekt előrehaladtával azonban azt szerettem volna, hogy egy fizikai digitális egységhez hasonlítson, amint azt sok zenész használja a valós fizikai világban.

A fizikai egységek nem rendelkeznek „SeekBar View” -val, és utánozni akartam azt a forgatógombot, amely egy tényleges egységnek lehet.

A forgatógombok nagyon hasznos felhasználói felület-vezérlők. Olyanok, mint egy csúszka vagy a SeekBar, sok helyzetben használhatók. Íme néhány előnyük:

  • Nagyon kevés ingatlant fogyasztanak az alkalmazásodban
  • Használhatók folyamatos vagy diszkrét értéktartományok vezérlésére
  • A felhasználók a valós alkalmazásokból azonnal felismerhetik őket
  • Nem szabványos Android-vezérlők, és így egyedi „egyedi” hangulatot kölcsönöznek alkalmazásának

Noha létezik néhány nyílt forráskódú gombkönyvtár az Android számára, egyikben sem találtam meg azt, amit kerestem.

Sokan túlteljesek voltak szerény igényeim miatt, olyan funkciókkal, mint a háttérképek beállítása vagy a csapok kezelése két vagy több módú művelethez stb.

Néhányuknak nem volt testreszabhatósága, azt akartam, hogy illeszkedjen a projektemhez, és saját gombképpel érkeztek.

Megint mások diszkrét érték- vagy pozíciótartományt feltételeztek. Sokan közülük a szükségesnél sokkal összetettebbnek tűntek.

Ezért úgy döntöttem, hogy magam tervezek egyet, ami önmagában szórakoztató kis projekt lett.

Ebben a cikkben megvitatom, hogyan építettem fel.

Tehát nézzük meg, hogyan hozhatunk létre egy forgatógombot.

Gomb megtervezése

Az első lépés a grafika elkészítése volt maga a gomb. Semmi esetre sem vagyok tervező, de felmerült bennem, hogy a „mélység” és a mozgás érzésének kialakításának kulcsa egy gombvezérlőben az, hogy a középen levő sugárirányú gradienst használom. Ez lehetővé tenné számomra a nyomott felület és a fényvisszaverődés illúziójának megteremtését.

A Sketch segítségével rajzoltam meg a gombot, majd exportáltam svg-be. Aztán visszahozhatóként importáltam az Android stúdióba.

A gomb a jelen cikk alján található GitHub projekt linken található.

A nézet létrehozása xml-ben

A Nézet létrehozásának első lépése egy elrendezés xml fájl létrehozása a res / layout mappában.

A nézet kódban teljesen létrehozható, de egy jó újrafelhasználható Android-nézetet xml-ben kell létrehozni.

Figyelje meg a címkét - ezt használjuk, mivel kibővítünk egy meglévő Android Layout osztályt, és ez az elrendezés lesz az elrendezés belső szerkezete.

ImageView-t fogunk használni a gombhoz, amelyet akkor forgatunk, amikor a felhasználó mozgatja.

Annak érdekében, hogy a gomb xml-rel konfigurálható legyen, létrehozunk egy attribútumot az értéktartományhoz, amelyet a gomb visszaad, valamint a rajzhoz, amelyet a látványhoz használ.

Létrehozunk egy attrs.xml fájlt a res / values ​​alatt.

Ezután hozzon létre egy új Kotlin osztályfájlt, a RotaryKnobView fájlt, amely kiterjeszti a RelativeLayout alkalmazást és megvalósítja a GestureDetector.OnGestureListener felületet.

A RelativeLayout-ot szülőtárolóként használjuk a vezérléshez, és az OnGestureListener alkalmazással kezeljük a gomb mozgásmozdulatait.

A @JvmOverloads csak egy parancsikon a View konstruktor mindhárom ízének felülírására.

Ezután inicializálunk néhány alapértelmezett értéket, és meghatározzuk az osztálytagokat.

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)

Megjegyzés az elválasztó változóval kapcsolatban - azt szerettem volna, ha a gombnak kezdő és véghelyzete van, ahelyett, hogy korlátlanul tudna forogni, hasonlóan a sztereó rendszer hangerőgombjához. A kezdő és a végpontot -150, illetve 150 fokon állítottam be. Tehát a gomb hatékony mozgása csak 300 fok.

Az osztó segítségével elosztjuk azt az értéktartományt, amelyen azt szeretnénk, hogy a gombunk visszatérjen ezekre a rendelkezésre álló 300 fokra - így a tényleges értéket a gomb helyzetének szöge alapján számíthatjuk ki.

Ezután inicializáljuk az összetevőt:

  • Felfújja az elrendezést.
  • Olvassa el az attribútumokat változókra.
  • Frissítse az osztót (az átadott minimum és maximum értékek támogatásához.
  • Állítsa be a képet.
 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) }

Az osztály még nem áll össze, mivel végre kell hajtanunk az OnGestureListener függvényeit. Kezeljük ezt most.

Felhasználói gesztusok észlelése

Az OnGestureListener interfész megköveteli, hogy hat funkciót valósítsunk meg:

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

Ezek közül el kell fogyasztanunk (true true) az onDown és az onTouchEvent programokon, és végre kell hajtanunk a mozgás bejelentkezését az onScroll alkalmazásban.

 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) {}

Itt van az onScroll megvalósítása. A hiányzó részeket a következő bekezdésben töltjük ki.

 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 }

Az onScroll két koordinátakészletet kap, e1 és e2, amelyek az eseményt kiváltó görgetés kezdő és vég mozgását jelentik.

Csak az e2 - a gomb új helyzete - érdekel minket, így animálhatjuk az elhelyezéshez és az érték kiszámításához.

Olyan funkciót használok, amelyet a következő részben áttekintünk a forgásszög kiszámításához.

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.

The Android Metronome app is also available on Google’s play store.