Android — RTL适配笔记

882 阅读5分钟

在海外发行App,对App进行多语言适配是必不可少的。多语言的适配其实不仅仅只是将文本内容进行翻译这么简单,在使用阿拉伯语或希伯来语的地区,用户的阅读习惯是从右到左,为了更好地用户体验,App还应该对布局实现RTL(Right-to-Left)适配。本文简单介绍如何进行RTL适配。

开启RTL支持

AndroidManifest中添加配置android:supportsRtl,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        ......
        android:supportsRtl="true">
       
    </application>
</manifest>

控件适配

从Android 4.2开始,大部分安卓提供的控件已经自动适配了RTL,我们需要做的是在布局文件中,将原本使用left或right声明的属性改为start或end。

示例:

<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:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/tv_use_left_right"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="20dp"
        android:background="@color/color_00A5FF"
        android:padding="10dp"
        android:text="use left or right"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/tv_use_start_end"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="20dp"
        android:background="@color/color_49E284"
        android:padding="10dp"
        android:text="use start or end"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tv_use_left_right" />
</androidx.constraintlayout.widget.ConstraintLayout>

效果如图:

LTRRTL
Screenshot_20240331_115911.pngScreenshot_20240331_115930.png

文本适配

数字文本

某些文本可能仅包含纯数字(例如消息数量),可以使用String.format()转换为对应语言的数字文本。

示例:

class AdapterRtlExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutAdapterRtlExampleActivityBinding

    private val exampleInt = 100102

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutAdapterRtlExampleActivityBinding.inflate(layoutInflater).also {
            setContentView(it.root)
        }

        binding.tvNumberExample.text = "$exampleInt"
        binding.tvNumberFormatExample.text = String.format("%d", exampleInt)
    }
}

效果如图:

英语阿语
Screenshot_20240331_121612.pngScreenshot_20240331_121634.png

混合语言文本

显示的文本可能包含多种语言,可以通过BidiFormatter.unicodeWrap()进行格式化。

示例:

要显示"收货地址为:15 Bay Street, Laurel, CA"。

class AdapterRtlExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutAdapterRtlExampleActivityBinding

    private val exampleText = "15 Bay Street, Laurel, CA"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutAdapterRtlExampleActivityBinding.inflate(layoutInflater).also {
            setContentView(it.root)
        }

        binding.tvMultiLanguage.text = getString(R.string.adapter_rlt_test, exampleText)
        binding.tvMultiLanguageFormat.text = getString(R.string.adapter_rlt_test, BidiFormatter.getInstance().unicodeWrap(exampleText))
    }
}

系统语言为阿语时,效果如图:

  • 蓝底 —— 未使用BidiFormatter
  • 绿底 —— 使用BidiFormatter
Screenshot_20240331_130502.png

自定义View适配

自定义View通常会通过onLayout或者onDraw方法来绘制View,自定义的绘制需要对RTL进行适配。本文以之前文章中实现的ExpandableFlowLayout为例,适配后代码如下:

  • ExpandableFlowLayout
class ExpandableFlowLayout : ViewGroup {

    private val defaultVerticalSpace = paddingTop + paddingBottom
    private val defaultHorizontalSpace = paddingStart + paddingEnd

    private var defaultShowRow = 2

    private var measureNeedExpandView = false
    var expand = false

    private var expandView: View

    private var elementDividerVertical: Int = DensityUtil.dp2Px(8)
    private var elementDividerHorizontal: Int = DensityUtil.dp2Px(8)

    private var isRtl = false

    var elementClickCallback: ((content: String) -> Unit)? = null

    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) {
        context.obtainStyledAttributes(attrs, R.styleable.ExpandableFlowLayout).run {
            defaultShowRow = getInt(R.styleable.ExpandableFlowLayout_default_show_row, 2)
            expand = getBoolean(R.styleable.ExpandableFlowLayout_default_expand_status, false)

            elementDividerVertical = getDimensionPixelSize(R.styleable.ExpandableFlowLayout_element_divider_vertical, DensityUtil.dp2Px(8))
            elementDividerHorizontal = getDimensionPixelSize(R.styleable.ExpandableFlowLayout_element_divider_horizontal, DensityUtil.dp2Px(8))

            recycle()
        }
        expandView = AppCompatImageView(context).apply {
            layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, DensityUtil.dp2Px(30))
            setImageResource(R.mipmap.icon_triangular_arrow_down)
            rotation = if (!expand) 0f else 180f
            setOnClickListener {
                expand = !expand
                rotation = if (!expand) 0f else 180f
                requestLayout()
            }
        }
        // 判断当前是否为RTL
        isRtl = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val rootWidth = MeasureSpec.getSize(widthMeasureSpec)
        var usedWidth = defaultHorizontalSpace
        var usedHeight = defaultVerticalSpace

        measureChild(expandView, widthMeasureSpec, heightMeasureSpec)

        var rowCount = 1
        for (index in 0 until childCount - 1) {
            val childView = getChildAt(index)
            if (childView != null) {
                // 测量当前子控件的宽高。
                measureChild(childView, widthMeasureSpec, heightMeasureSpec)
                val realChildViewUsedWidth = childView.measuredWidth + elementDividerHorizontal
                val realChildViewUsedHeight = childView.measuredHeight + elementDividerVertical

                if (usedHeight == defaultVerticalSpace) {
                    usedHeight += realChildViewUsedHeight
                }

                // 当前子控件宽度加上之前已用宽度大于根布局宽度,需要换行。
                if (usedWidth + realChildViewUsedWidth > rootWidth) {
                    // 换行
                    rowCount++

                    // 当前为未展开状态,并且此时行数已经超过了默认显示行数,跳过后续的测量。
                    if (!expand && rowCount > defaultShowRow) {
                        break
                    }

                    // 重置已用宽度
                    usedWidth = defaultHorizontalSpace
                    // 增加已用高度
                    usedHeight += realChildViewUsedHeight
                }

                usedWidth += realChildViewUsedWidth

                if (index == childCount - 2 && expand && rowCount > defaultShowRow) {
                    // 展开状态下的最后一个元素,
                    // 此时判断能否再放下展开控件,不能则需要增加一行用于显示展开控件。
                    if (usedWidth + expandView.measuredWidth > rootWidth) {
                        usedHeight += expandView.measuredHeight + elementDividerVertical
                    }
                }
            }
        }
        measureNeedExpandView = rowCount > defaultShowRow
        setMeasuredDimension(rootWidth, usedHeight)
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        val availableWidth = right - left
        var usedWidth = defaultHorizontalSpace

        // RTL模式下,从右侧开始添加View
        // 需要注意的是,LTR和RTL模式下,marginStart、marginEnd、paddingStart和PaddingEnd获取的值是一致的
        // 因此需要自己处理不同模式下的边距
        var positionX = if (isRtl) availableWidth - paddingEnd else paddingStart
        var positionY = paddingTop

        var rowCount = 1
        for (index in 0 until childCount - 1) {
            val childView = getChildAt(index)
            if (childView != null) {
                val realChildViewUsedWidth = childView.measuredWidth + elementDividerHorizontal
                val realChildViewUsedHeight = childView.measuredHeight + elementDividerVertical

                val changeRowCondition = if ((!expand && rowCount == defaultShowRow)) {
                    // 未展开状态,并且当前行已经是默认显示行,已用空间需要加上展开控件的空间
                    usedWidth + realChildViewUsedWidth + (if (measureNeedExpandView) expandView.measuredWidth else 0) > availableWidth
                } else {
                    usedWidth + realChildViewUsedWidth > availableWidth
                }
                if (changeRowCondition) {
                    // 换行
                    rowCount++

                    // 当前为未展开状态,并且此时行数已经超过了默认显示行数,跳过后续处理
                    if (!expand && rowCount > defaultShowRow) {
                        childView.layout(0, 0, 0, 0)
                        break
                    }

                    // 重置已用宽度
                    usedWidth = defaultHorizontalSpace
                    // 新行开始的x轴坐标重置
                    positionX = if (isRtl) availableWidth - paddingEnd else paddingStart
                    // 新行开始的y轴坐标增加
                    positionY += realChildViewUsedHeight
                }

                if (isRtl) {
                    // RTL模式下,从右侧开始添加View
                    childView.layout(positionX - childView.measuredWidth, positionY, positionX, positionY + childView.measuredHeight)
                    positionX -= realChildViewUsedWidth
                } else {
                    childView.layout(positionX, positionY, positionX + childView.measuredWidth, positionY + childView.measuredHeight)
                    positionX += realChildViewUsedWidth
                }
                usedWidth += realChildViewUsedWidth

                if (index == childCount - 2 && expand && rowCount > defaultShowRow) {
                    // 展开状态下的最后一个元素,
                    // 此时判断能否再放下展开控件,不能则需要增加一行用于显示展开控件。
                    if (usedWidth + expandView.measuredWidth > availableWidth) {
                        positionX = if (isRtl) availableWidth - paddingEnd else paddingStart
                        // 新行开始的y轴坐标增加
                        positionY += realChildViewUsedHeight
                    }
                }
            }
        }
        if (measureNeedExpandView) {
            if (isRtl) {
                // RTL模式下,从右侧开始添加View
                expandView.layout(positionX - expandView.measuredWidth, positionY, positionX, positionY + expandView.measuredHeight)
            } else {
                expandView.layout(positionX, positionY, positionX + expandView.measuredWidth, positionY + expandView.measuredHeight)
            }
        } else {
            expandView.layout(0, 0, 0, 0)
        }
    }

    @SuppressLint("InflateParams")
    fun setData(data: List<String>) {
        removeAllViews()
        for (content in data) {
            LayoutInflater.from(context).inflate(R.layout.layout_example_flow_item, null, false).apply {
                layoutParams = MarginLayoutParams(MarginLayoutParams.WRAP_CONTENT, DensityUtil.dp2Px(30))
                findViewById<AppCompatTextView>(R.id.tv_example_flow_item_content).run {
                    text = content
                    gravity = Gravity.CENTER_VERTICAL
                    setOnClickListener {
                        elementClickCallback?.invoke(content)
                    }
                }
                addView(this)
            }
        }
        addView(expandView)
    }
}
  • 示例页面
class AdapterRtlExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutAdapterRtlExampleActivityBinding

    private val exampleData = arrayOf("测试测试测试测试", "aadaada", "hahaha", "这是一个测试数据", "yyddd", "测试用测试用", "test data", "example", "akdjfj", "yyds")

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = LayoutAdapterRtlExampleActivityBinding.inflate(layoutInflater).also {
            setContentView(it.root)
        }

        binding.btnAddData.setOnClickListener {
            val data = ArrayList<String>()
            // 从测试数据中随机生成8个元素
            repeat(8) {
                data.add(exampleData.random())
            }
            binding.eflExampleDataContainer.setData(data)
        }
    }
}

效果如图:

LTRRTL
Screen_recording_202 -original-original.gifScreen_recording_202.gif

示例

演示代码已在示例Demo中添加。

ExampleDemo github

ExampleDemo gitee