Kad mu je učitelj klavira moga sina rekao da bi trebao koristiti metronom za vježbanje vremena, iskoristio sam to kao priliku da naučim Kotlina. Odlučio sam naučiti jezik i Androidov ekosustav kako bih mogao izraditi aplikaciju Metronome.
Moja početna implementacija koristila je SeekBar za kontrolu BPM-a (otkucaja u minuti) - brzine kojom otkucava metronom.
No kako je projekt napredovao, želio sam da nalikuje fizičkoj digitalnoj jedinici, kakvu koriste mnogi glazbenici u stvarnom fizičkom svijetu.
Fizičke jedinice nemaju "SeekBar View", a ja sam želio oponašati okretni gumb koji bi stvarna jedinica mogla imati.
Okretne tipke vrlo su korisne kontrole korisničkog sučelja. Oni su slični klizaču ili SeekBaru, korisni u mnogim situacijama. Evo nekoliko njihovih prednosti:
- Oni troše vrlo malo nekretnina u vašoj aplikaciji
- Mogu se koristiti za upravljanje kontinuiranim ili diskretnim rasponima vrijednosti
- Korisnici ih odmah prepoznaju iz aplikacija iz stvarnog svijeta
- Oni nisu standardne Android kontrole i stoga pružaju jedinstveni "prilagođeni" osjećaj vašoj aplikaciji
Iako postoji nekoliko knjižnica s otvorenim izvornim gumbima za Android, ni u jednoj nisam pronašao ono što sam tražio.
Mnogi su bili pretjerani za moje skromne potrebe, s funkcijama poput postavljanja pozadinskih slika ili rukovanja slavinama za dva ili više načina rada, itd.
Neki nisu imali prilagodljivost koju sam želio uklopiti u svoj projekt, a došli su sa vlastitom slikom gumba.
Treći su pretpostavljali diskretan raspon vrijednosti ili položaja. A mnogi od njih činili su se mnogo složenijima nego što je bilo potrebno.
Stoga sam odlučio dizajnirati jedan - što se samo po sebi pretvorilo u zabavan mali projekt.
U ovom ću članku razgovarati o tome kako sam ga izgradio.

Pa da vidimo kako možemo stvoriti okretni gumb.
Dizajniranje gumba
Prvi korak bio je stvaranje grafike za sam gumb. Nikako nisam dizajner, ali palo mi je na pamet da bi ključ stvaranja osjećaja "dubine" i kretanja u komandi gumba bio korištenje radijalnog gradijenta izvan centra. To bi mi omogućilo stvaranje iluzije depresivne površine i refleksije svjetlosti.
Sketch sam koristio za crtanje gumba, a zatim sam ga izvezao na svg. Zatim sam ga vratio u Android studio kao nacrt.
Gumb za crtanje možete pronaći na poveznici projekta GitHub na dnu ovog članka.

Izrada prikaza u xml-u
Prvi korak u stvaranju pogleda je stvaranje xml datoteke layout u mapi res / layout.
Pogled se može u potpunosti stvoriti u kodu, ali dobar prikaz za višekratnu upotrebu u Androidu treba stvoriti u xml-u.
Primijetite oznaku - to ćemo koristiti, jer ćemo proširiti postojeću klasu Android Layout i ovaj će izgled biti unutarnja struktura tog izgleda.
Za gumb ćemo koristiti ImageView koji ćemo okretati dok ga korisnik pomiče.
Kako bismo gumb učinili konfigurabilnim pomoću xml, stvorit ćemo atribute za raspon vrijednosti koje će gumb vratiti, kao i za crtanje koje će koristiti za vizualne elemente.
Stvorit ćemo datoteku attrs.xml pod res / values.
Zatim stvorite novu datoteku klase Kotlin, RotaryKnobView, koja proširuje RelativeLayout i implementira sučelje GestureDetector.OnGestureListener.
Koristit ćemo RelativeLayout kao nadređeni spremnik za kontrolu i implementirati OnGestureListener za rukovanje pokretima gumba.
@JvmOverloads samo je prečac za nadjačavanje sva tri okusa konstruktora View.
Dalje ćemo inicijalizirati neke zadane vrijednosti i definirati članove klase.
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)
Napomena o varijabli razdjelnika - Htio sam da gumb ima početni i krajnji položaj, umjesto da se može rotirati unedogled, slično gumbu za glasnoću na stereo sustavu. Početnu i završnu točku postavio sam na -150, odnosno 150 stupnjeva. Dakle, učinkovito kretanje gumba je samo 300 stupnjeva.
Dijelnikom ćemo raspodijeliti raspon vrijednosti za koje želimo da se naš gumb vraća na ovih raspoloživih 300 stupnjeva - tako da možemo izračunati stvarnu vrijednost na temelju kuta položaja gumba.
Dalje, inicijaliziramo komponentu:
- Napuhajte izgled.
- Pročitajte atribute u varijable.
- Ažurirajte razdjelnik (za podršku prosljeđivanju u minimalnim i maksimalnim vrijednostima.
- Postavite sliku.
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) }
Klasa se još neće kompajlirati, jer moramo implementirati funkcije OnGestureListener. Riješimo to sada.
Otkrivanje korisničkih gesta
Sučelje OnGestureListener zahtijeva da implementiramo šest funkcija:
onScroll, onTouchEvent, onDown, onSingleTapUp, onFling, onLongPress, onShowPress.
Od toga moramo potrošiti (vratiti true) na onDown i onTouchEvent te implementirati prijavu kretanja u 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) {}
Evo implementacije onScroll-a. Dijelove koji nedostaju popunit ćemo u sljedećem odlomku.
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 prima dva skupa koordinata, e1 i e2, koji predstavljaju pokrete početka i kraja pomicanja koji su pokrenuli događaj.
Zanima nas samo e2 - novi položaj gumba - kako bismo ga mogli animirati u položaj i izračunati vrijednost.
Koristim funkciju koju ćemo pregledati u sljedećem odjeljku za izračunavanje kuta rotacije.
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.
- 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.
- 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.
- 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.