
Θέλεις να σχεδιάσεις έναν κύκλο και να εμφανίσεις έναν χαρακτήρα (γράμμα, αριθμό ή ακόμη και emoji) ακριβώς στο κέντρο του.
Αν δοκίμασες να το κάνεις με ένα TextView, θα διαπίστωσες ότι ο χαρακτήρας δε φαίνεται να είναι ακριβώς στο κέντρο.
Σε αυτό το άρθρο θα σου δείξω πώς να το κάνεις με ένα custom view, χρησιμοποιώντας το Canvas API.
Πρώτα απ’ όλα, ο κώδικας Link to heading
Παρακάτω θα βρεις τον κώδικα που λύνει το πρόβλημα.
Αν σε ενδιαφέρει να καταλάβεις και πώς λειτουργεί, τα επόμενα sections εξηγούν τα κύρια σημεία του.
CircleTextView.kt
package dev.anastasioscho.canvascentertextincircle
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
class CircleTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = context.getColor(R.color.fill_color)
}
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
color = context.getColor(R.color.stroke_color)
strokeWidth = resources.getDimension(R.dimen.stroke_width)
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.LEFT
color = context.getColor(R.color.text_color)
textSize = resources.getDimension(R.dimen.text_size)
typeface = Typeface.SANS_SERIF
}
private val debugPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.STROKE
strokeWidth = resources.getDimension(R.dimen.debug_line_width)
}
private val textBounds = Rect()
private val shouldShowDebugLine = true
private val text = "g"
init {
textPaint.getTextBounds(text, 0, text.length, textBounds)
}
override fun onDraw(canvas: Canvas) {
val cx = width / 2f
val cy = height / 2f
drawCircleShape(canvas, cx, cy)
drawText(canvas, cx, cy)
drawDebugLine(canvas, cy)
}
private fun drawText(canvas: Canvas, cx: Float, cy: Float) {
val textX = cx - textBounds.exactCenterX()
val textY = cy - textBounds.exactCenterY()
canvas.drawText(text, textX, textY, textPaint)
}
private fun drawCircleShape(canvas: Canvas, cx: Float, cy: Float) {
val radius = minOf(width, height) / 2f - strokePaint.strokeWidth / 2f
canvas.drawCircle(cx, cy, radius, fillPaint)
canvas.drawCircle(cx, cy, radius, strokePaint)
}
private fun drawDebugLine(canvas: Canvas, cy: Float) {
if (shouldShowDebugLine) {
canvas.drawLine(0f, cy, width.toFloat(), cy, debugPaint)
}
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.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:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background_color"
tools:context=".MainActivity">
<dev.anastasioscho.canvascentertextincircle.CircleTextView
android:id="@+id/circleTextView"
android:layout_width="50dp"
android:layout_height="50dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="background_color">#FFF0F2F5</color>
<color name="stroke_color">#FF90CAF9</color>
<color name="text_color">#FF37474F</color>
<color name="fill_color">#FFE8EAF6</color>
</resources>
dimens.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="text_size">18sp</dimen>
<dimen name="stroke_width">2dp</dimen>
<dimen name="debug_line_width">1dp</dimen>
</resources>
Τί είναι το baseline Link to heading
Φαντάσου ότι γράφεις σε ένα τετράδιο. Το baseline είναι η οριζόντια γραμμή πάνω στην οποία “κάθονται” τα γράμματα.
Γράμματα όπως τα “a”, “n” και “1”, βρίσκονται σχεδόν εξ’ ολοκλήρου πάνω από αυτή τη γραμμή.
Άλλα γράμματα, όπως τα “g”, “p” και “y”, έχουν ένα κομμάτι που εκτείνεται κάτω από αυτήν (descender).
Αυτό μπορείς να το δεις στις εικόνες (f), (g) και (h). Η κόκκινη οριζόντια γραμμή είναι το baseline.
Τα font metrics Link to heading
Κάθε font, έχει κάποιες μετρήσεις που περιγράφουν πόσο χώρο χρειάζεται πάνω και κάτω από το baseline.
Στο Android, τις μετρήσεις αυτές μπορούμε να τις πάρουμε από το Paint.FontMetrics:
- ascent: Η συνιστώμενη απόσταση πάνω από το baseline.
- descent: Η συνιστώμενη απόσταση κάτω από το baseline.
- top: Η μέγιστη απόσταση πάνω από το baseline.
- bottom: Η μέγιστη απόσταση κάτω από το baseline.
- leading: Η έξτρα απόσταση μεταξύ των γραμμών.
Τα ascent και top έχουν αρνητική τιμή, ενώ τα descent και bottom θετική.
Αυτές οι μετρήσεις αφορούν το font στο σύνολό του και δεν είναι ξεχωριστές για κάθε glyph.
Το typographic box Link to heading
Κάθε font, δεσμεύει έναν τυπογραφικό χώρο για κάθε γραμμή κειμένου. Αυτόν τον χώρο μπορούμε να τον φανταστούμε ως ένα κουτί, το οποίο στο εξής θα αποκαλούμε typographic box.
Τα οριζόντια όριά του εξαρτώνται από το πλάτος του κειμένου.
Τα κατακόρυφα όριά του εξαρτώνται από τις τιμές των font metrics: το επάνω όριο ορίζεται είτε από το ascent είτε από το top, ενώ το κάτω όριο από το descent ή το bottom.
Για παράδειγμα, σε ένα TextView με android:includeFontPadding="true", τα επάνω και κάτω όρια ορίζονται από τα top και bottom αντίστοιχα, ενώ με android:includeFontPadding="false" ορίζονται από τα ascent και descent.
Αυτό το κουτί δεν αντιστοιχεί στα πραγματικά pixels του κειμένου που σχεδιάζεται, αλλά είναι ένας χώρος που το font δεσμεύει ανεξάρτητα από το κείμενο που εμφανίζεται.
Για παράδειγμα, ακόμα και αν σχεδιάσεις μόνο τον χαρακτήρα “1” που δεν έχει descender, το typographic box θα εξακολουθεί να περιλαμβάνει χώρο κάτω από το baseline.
Αυτό φαίνεται στην εικόνα (g), όπου το typographic box είναι η χρωματισμένη περιοχή.
Γιατί δεν λειτουργεί το TextView Link to heading
Η πιο απλή λύση θα ήταν να χρησιμοποιήσουμε ένα απλό TextView με τα παρακάτω properties:
android:includeFontPadding="false"
android:gravity="center"
Όπως φαίνεται και στην εικόνα (a), με αυτόν τον τρόπο το κείμενο δείχνει να είναι ελαφρώς εκτός κέντρου.
Αυτό συμβαίνει επειδή το android:gravity="center" κεντράρει το typographic box μέσα στο TextView. Επειδή όμως το ascent και descent μπορεί να διαφέρουν, το typographic box δεν είναι απαραίτητα συμμετρικό γύρω από το baseline, με αποτέλεσμα οι χαρακτήρες να μη φαίνονται οπτικά κεντραρισμένοι.
Παρατήρησε επίσης ότι επειδή θέτουμε android:includeFontPadding="false", για τα κατακόρυφα όρια του typographic box χρησιμοποιούνται τα ascent και descent των font metrics.
Τέλος, το ίδιο αποτέλεσμα θα μπορούσαμε να πετύχουμε και στο δικό μας custom view, αν στην onDraw() υπολογίζαμε το textY ως εξής:
val textY = cy - (textPaint.ascent() + textPaint.descent()) / 2f
αντί για:
val textY = cy - textBounds.exactCenterY()
Κεντράρισμα με getTextBounds() Link to heading
Η getTextBounds(), επιστρέφει το μικρότερο ορθογώνιο που χωράει ακριβώς τα pixels που πρόκειται να σχεδιαστούν για ένα String. Το ορθογώνιο αυτό ονομάζεται boundary box.
Οι exactCenterX() και exactCenterY() δίνουν το κέντρο αυτού του ορθογωνίου.
Στη δική μας περίπτωση, όπου το String αποτελείται από ένα και μόνο χαρακτήρα, το boundary box περικλείει μόνο αυτόν τον χαρακτήρα.
Αφαιρώντας το κέντρο του boundary box από το κέντρο του κύκλου (cx, cy), τοποθετούμε το boundary box (και κατ’ επέκταση τον χαρακτήρα) ακριβώς στο κέντρο του κύκλου, όπως φαίνεται στην εικόνα (b).
Διαχείριση περισσότερων χαρακτήρων Link to heading
Εάν εφαρμόσουμε τη λύση αυτή σε ένα String όπως το “1g”, όπου οι χαρακτήρες έχουν διαφορετικό boundary box, το αποτέλεσμα θα μοιάζει με αυτό της εικόνας (c).
Το “1” φαίνεται να βρίσκεται υψηλότερα, καθώς το boundary box που επιστρέφει η getTextBounds() περιλαμβάνει το descender του “g”, με αποτέλεσμα το visual center του boundary box να μετατοπίζεται προς τα κάτω.
Για να αποφύγουμε αυτό το πρόβλημα, μπορούμε να σχεδιάσουμε κάθε χαρακτήρα ξεχωριστά, κεντράροντάς τον κατακόρυφα με βάση το δικό του boundary box:
// Add the following private val:
private val charBounds = Rect()
// Delete the init block. It is no longer needed since we call getTextBounds()
// for each character inside drawText().
private fun drawText(canvas: Canvas, cx: Float, cy: Float) {
val totalWidth = textPaint.measureText(text)
var currentX = cx - totalWidth / 2f
for (char in text) {
val charStr = char.toString()
textPaint.getTextBounds(charStr, 0, charStr.length, charBounds)
val charY = cy - charBounds.exactCenterY()
canvas.drawText(charStr, currentX, charY, textPaint)
currentX += textPaint.measureText(charStr)
}
}
Έτσι, κάθε χαρακτήρας κεντράρεται ανεξάρτητα στον κατακόρυφο άξονα, με αποτέλεσμα ένα “g” και ένα “1” να έχουν διαφορετικό Y.
Αυτό φαίνεται στις εικόνες (d) και (e).
Αξίζει να σημειωθεί ότι με αυτόν τον τρόπο οι χαρακτήρες παύουν να μοιράζονται το ίδιο baseline, κάτι που θα μπορούσε να δυσκολέψει την ανάγνωση.
Τέλος, με αυτήν την υλοποίηση, θα πρέπει να υπολογίζουμε ξεχωριστά και το Χ για κάθε χαρακτήρα.