安卓中最長使用的控件就是TextView
,一般而言,使用時只是簡單的設置文字,大小,顏色,尺寸。稍微複雜一些的,我們使用Span
標籤,Drawable***
等富文本。可能爲了顯示效果,還會進行padding
,margin
調整,以及 跑馬燈 效果的展示。
有時我們可能需要設置文字的行間隔,於是就用了lineSpacingExtra
, lineSpacingMultiplier
;
有時想設置文字拉伸效果,於是會配置textScaleX
或者更進一步,想要修改文本間隔,這時需要設置letterSpacing
。。。
類似的情況還有很多,不過這些不是目前考慮的重點,事實上以上的內容雖然比較繁瑣(單從上萬行的 TextView 源碼便可以看出該控件的功能性),但大多有規律可循,因此並不難使用。
比較麻煩的是,平時進行開發時,我們參照的UI設計圖(一般爲ps原件)一般都是這個樣子 的 :
可以直接測量出兩個TextView
之間的間距爲33dp,但在實際開發中,這裏是無法照抄該尺寸的,TextView
在繪製文字的時候,會自帶一些padding效果,因此按照33dp顯示出來的效果將會比設計圖上大的多。
通常情況,開發人員在指定間隔時,會採用比該值小一些的,比較規範的間隔,比如常用的一些間隔尺寸:16dp
、32dp
、48dp
、64dp
、24dp
,雖然大體上效果類似,但不免有些‘碓答案’
的嫌疑。
如果要明白其中的貓膩,就需要去了解一些文字的顯示方式了
1、TextView中文字繪製的規則?
在自定義View控件時,都會瞭解到Canvas
的存在,我們看到的效果都是直接在畫布上draw
出來的,文字也不例外。
TextView中會保持一個畫筆:Paint
,我們可以在任何地方拿到該對象
TextPaint paint = tv.getPaint();
TextPaint
是Paint
的子類,文本的顯示等效果都是由該畫筆
在Canvas
上勾勒出來的,繪製文字的方法有這些:
無論哪個重載的方法,都需要傳入一個座標值:(x,y)
這個座標是文字繪製的起點,但並不是文字左下角的座標,已TextPaint
定義的規則來看的話,大概是這個樣子:
這裏列舉了多種類型的文本信息包括大小寫,上標,表情等;圖中顯示出了五條線,限定了文字繪製的模版,代表的含義可以查看 FontMetrics類
源碼:
// paint爲TextPaint類的實例
val fontMetrics = paint.fontMetrics
public static class FontMetrics {
/**
* The maximum distance above the baseline for the tallest glyph in
* the font at a given text size.
*/
public float top;
/**
* The recommended distance above the baseline for singled spaced text.
*/
public float ascent;
/**
* The recommended distance below the baseline for singled spaced text.
*/
public float descent;
/**
* The maximum distance below the baseline for the lowest glyph in
* the font at a given text size.
*/
public float bottom;
/**
* The recommended additional space to add between lines of text.
*/
public float leading;
}
爲了直觀的看到效果,我們將TextView
的padding
和margin
值設置 爲0dp
,設置文字大小爲100sp
,然後對比着說明FontMetrics類
中各字段的含義:
- top:控件的最頂部,取值 -209.5999…
- ascent:控件文字可達的最頂部,取值 -185.59999…
- leading:即baseline,文字基準線,繪製文字時,y座標爲該值,取值 0
- descent:控件文字可達的最底部,取值 48.8
- bottom:控件的最底部,取值 55.4
注:需要注意的是
這裏所有的取值,都是在TextView爲默認樣式下獲取的
該TextView的寬高爲:width: 1280 || height: 266
有了上面的具體數據,我們不難發現一些事實:
* top 和 bottom 之間距離,正好等於 TextView 的高度 ;(可能會有數dp的偏差,這個是由於繪製時,小數向上或向下取值導致的)
* ascent 和 descent 之間的距離,正好是文字內容可達的最大高度;(但從上面可以看出,不同的字符佔據的位置是不同的,很多都沒有達到最大高度,這個可以參考最後給出的幾張包含表情符號的圖)
* leading和baseline都是指基準線,也是某些情況下的baseline,我們使用 ConstraintLayout 約束佈局時,其中會有 layout_constraintBaseline_toBaselineOf 屬性,可以指定兩個TextView的基準線對齊
在TextView
中,還有這樣的一個屬性:
android:includeFontPadding="true"
該屬性爲false
時,TextView的高度就變成了 ascent 和 descent 之間的距離
上面的結果正好驗證了我們之前的結論,即:
- 在includeFontPadding 爲 true時,控件高度爲 top 到 bottom 之間距離
- 在includeFontPadding 爲 false時,控件高度爲ascent到descent之間距離
當然,這一切的前提是沒有drawable和padding
等影響因素的存在。在知道了文字所處的 空間 規則,我們需要怎麼來處理 PS圖 與 實際效果 的差別呢?
2、根據上方控件改變自身margin值
在做嘗試之前,需要先明確一些事實:
我們進行UI佈局時,情況要比簡單的基本佈局複雜的多,因此這裏做一些限制,在一些
既定使用
的情況下,來完成目標的設定;
這些既定使用
的規則爲:
- 僅限LinearLayout佈局,且只考慮 VERTICAL 情況下的排版方式
- 在LinearLayout佈局中,ActualTextView的includeFontPadding屬性必須爲true,否則具體計算的結果會有偏差
- 在LinearLayout佈局中,我們約定 topMargin 屬性優先,bottomMargin將不影響佈局效果
- LinearLayout佈局最後一個Child距離底部的距離不做處理
- 只能在xml中進行佈局設置
我們姑且按照上面的規則嚴格執行,當然,這些條件相當苛刻,不過這裏只是尋求一種解,並不考慮所有的情況。注:事實上考慮所有的情況將相當複雜,可能邏輯將無法進行下去
好了,在做出瞭如此多限定後,這裏將對TextView進行簡單的改造,首先,我們需要獲取到原始的 topMargin
值:
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
super.setLayoutParams(params)
(layoutParams as ViewGroup.MarginLayoutParams?)?.let {
originMarginTop = it.topMargin
originMarginBottom = it.bottomMargin
}
}
該值在設定後,不要輕易改變,不然可能無法還原回去;
然後我們在 TextView 添加到 父ViewGroup之後,去動態的改變 topMargin
,使之滿足我們的需要:
override fun hasFocus(): Boolean {
if (firstInvokeHasFocus) {
(parent is LinearLayout && (parent as LinearLayout).orientation == LinearLayout.VERTICAL).let {
//有父類的情況下,查看當前是否有includePadding
if (includeFontPadding) {
//判斷自身的marginTop值是否存在
if (originMarginTop != 0) {
//如果存在,則進行縮減,縮減的尺寸爲: 自身 (ascent - top) + 上層佈局(若爲ActualTextView且includeFontPadding爲true的話)(bottom - descent)
var delete = paint.fontMetrics.ascent - paint.fontMetrics.top
val position = (parent as ViewGroup).indexOfChild(this@ActualTextView)
val before: View? = (parent as ViewGroup).getChildAt(position - 1)
if (before is ActualTextView && before.includeFontPadding) {
delete += before.paint.fontMetrics.bottom - before.paint.fontMetrics.descent
}
//爲當前的marginTop賦值
(layoutParams as LinearLayout.LayoutParams).topMargin = (originMarginTop - delete).toInt().also { if (it < 0) 0 else it }
}
}
}
firstInvokeHasFocus = false
}
return super.hasFocus()
}
邏輯很簡單,就是將上個兄弟View的底部多餘部分,與當前View的頂部多餘部分
給減去。
這裏將有一種意外情況出現:我們需要減去的部分,比設置的margin值還要大
在字體比較大,margin比較小時,這種情況是很容易出現的,不過還好,LinearLayout
佈局允許設置負的margin值
,這也是上面要做出諸多限制的原因。
爲了顯示效果清晰一些,我們加上一些帶透明度的背景。
六個TextView對應的topmargin
分別爲:0dp,20dp、10dp、5dp、1dp、0dp
在經過處理後,margin 值對應的變成了:
09-30 15:23:12.864 3021-3021/com.example.test.tv E/topMargin::::: -12
09-30 15:23:12.866 3021-3021/com.example.test.tv E/topMargin::::: 24
09-30 15:23:12.868 3021-3021/com.example.test.tv E/topMargin::::: 4
09-30 15:23:12.870 3021-3021/com.example.test.tv E/topMargin::::: -5
09-30 15:23:12.872 3021-3021/com.example.test.tv E/topMargin::::: -13
09-30 15:23:12.874 3021-3021/com.example.test.tv E/topMargin::::: -15
去除背景,添加5條line,查看佈局情況:
可以看到倒數第二條綠線比較粗,這是因爲在設置了 top-margin爲0dp
的情況下,上個 TextView 的 descent
與 當前 TextView 的 ascent
重合在了一起。
3、結語&代碼
如此一來,就完成了既定規則下,設計圖與效果圖的統一,不過如果需要適應多種情況,則需要真正使用時,進行專門的定製了。這裏貼上之前測試使用的自定義TextView
的源碼:
/**
* function : 包含margin的TextView
*
* Created on 2018/9/29 18:35
* @author mnlin
*/
class ActualTextView : AppCompatTextView {
/**
* 距離的原始高度
*/
private var originMarginTop: Int = 0
private var originMarginBottom: Int = 0
/**
* 第一次調用hasFocus方法
*/
var firstInvokeHasFocus = true
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
initPlugins()
}
/**
* 初始化插件類
*/
private fun initPlugins() {
setSingleLine()
ellipsize = TextUtils.TruncateAt.MARQUEE
marqueeRepeatLimit = -1
}
override fun setLayoutParams(params: ViewGroup.LayoutParams?) {
super.setLayoutParams(params)
(layoutParams as ViewGroup.MarginLayoutParams?)?.let {
originMarginTop = it.topMargin
originMarginBottom = it.bottomMargin
}
}
override fun hasFocus(): Boolean {
if (firstInvokeHasFocus) {
(parent is LinearLayout && (parent as LinearLayout).orientation == LinearLayout.VERTICAL).let {
//有父類的情況下,查看當前是否有includePadding
if (includeFontPadding) {
//判斷自身的marginTop值是否存在
if (originMarginTop != 0 || true) {
//如果存在,則進行縮減,縮減的尺寸爲: 自身 (ascent - top) + 上層佈局(若爲ActualTextView且includeFontPadding爲true的話)(bottom - descent)
var delete = paint.fontMetrics.ascent - paint.fontMetrics.top
val position = (parent as ViewGroup).indexOfChild(this@ActualTextView)
val before: View? = (parent as ViewGroup).getChildAt(position - 1)
if (before is ActualTextView && before.includeFontPadding) {
delete += before.paint.fontMetrics.bottom - before.paint.fontMetrics.descent
}
//爲當前的marginTop賦值
(layoutParams as LinearLayout.LayoutParams).topMargin = (originMarginTop - delete).toInt().also {
Log.e("topMargin::::", it.toString())
if (it < 0)
0
else
it
}
}
}
}
firstInvokeHasFocus = false
}
return super.hasFocus()
}
override fun isFocused(): Boolean {
return true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val measuredWidth = measuredWidth
val paint = paint
val fontMetrics = paint.fontMetrics
val top = if (includeFontPadding) fontMetrics.top else fontMetrics.ascent
paint.color = Color.BLACK
canvas.drawLine(0f, fontMetrics.leading - top, measuredWidth.toFloat(), fontMetrics.leading - top, paint)
paint.color = Color.RED
canvas.drawLine(0f, fontMetrics.top - top, measuredWidth.toFloat(), fontMetrics.top - top, paint)
canvas.drawLine(0f, fontMetrics.bottom - top, measuredWidth.toFloat(), fontMetrics.bottom - top, paint)
paint.color = Color.GREEN
canvas.drawLine(0f, fontMetrics.ascent - top, measuredWidth.toFloat(), fontMetrics.ascent - top, paint)
canvas.drawLine(0f, fontMetrics.descent - top, measuredWidth.toFloat(), fontMetrics.descent - top, paint)
paint.color = Color.BLUE
canvas.drawLine(100f, paint.baselineShift - top, measuredWidth.toFloat(), paint.baselineShift - top, paint)
Log.e("fontMetrics::::", "top:${fontMetrics.top} ; ascent:${fontMetrics.ascent} ; leading:${fontMetrics.leading} ; descent:${fontMetrics.descent} ; bottom:${fontMetrics.bottom}")
Log.e("width: || height: ", width.toString() + " || " + height)
}
}
以及其中使用到的複雜的表情等:
<string name="common_test">亂2JYjy²😃麣👇√∈✔₎</string>
最後使用的佈局文件xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
tools:context=".MainActivity">
<!--android:background="#00005555"-->
<com.example.test.tv.ActualTextView
android:id="@+id/tv_one"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="0dp"
android:background="#00005555"
android:includeFontPadding="true"
android:padding="0dp"
android:singleLine="true"
android:text="@string/common_test"
android:textSize="50sp"/>
<!--android:background="#00555500"-->
<com.example.test.tv.ActualTextView
android:id="@+id/tv_two"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="20dp"
android:background="#00555500"
android:includeFontPadding="true"
android:letterSpacing="0"
android:padding="0dp"
android:singleLine="true"
android:text="@string/common_test"
android:textSize="50sp"/>
<com.example.test.tv.ActualTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="10dp"
android:background="#00005555"
android:includeFontPadding="true"
android:letterSpacing="0"
android:padding="0dp"
android:singleLine="true"
android:text="@string/common_test"
android:textSize="50sp"/>
<com.example.test.tv.ActualTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="5dp"
android:background="#00555500"
android:includeFontPadding="true"
android:letterSpacing="0"
android:padding="0dp"
android:singleLine="true"
android:text="@string/common_test"
android:textSize="50sp"/>
<com.example.test.tv.ActualTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="1dp"
android:background="#00005555"
android:includeFontPadding="true"
android:letterSpacing="0"
android:padding="0dp"
android:singleLine="true"
android:text="@string/common_test"
android:textSize="50sp"/>
<com.example.test.tv.ActualTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="0dp"
android:background="#00555500"
android:includeFontPadding="true"
android:letterSpacing="0"
android:padding="0dp"
android:singleLine="true"
android:text="@string/common_test"
android:textSize="50sp"/>
</LinearLayout>