How to detemine the current direction of a View (R

2019-06-18 15:09发布

Background

It's possible to get the current locale direction, using this:

val isRtl=TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL

It's also possible to get the layout direction of a view, if the developer has set it:

val layoutDirection = ViewCompat.getLayoutDirection(someView)

The problem

The default layoutDirection of a view isn't based on its locale. It's actually LAYOUT_DIRECTION_LTR .

When you change the locale of the device from LTR (Left-To-Right) locale (like English) to RTL (Right-To-Left) locale (like Arabic or Hebrew) , the views will get aligned accordingly, yet the values you get by default of the views will stay LTR...

This means that given a view, I don't see how it's possible to determine the correct direction it will go by.

What I've tried

I've made a simple POC. It has a LinearLayout with a TextView:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    android:id="@+id/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_vertical" tools:context=".MainActivity">

    <TextView
        android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="Hello World!"/>

</LinearLayout>

In code, I write the direction of the locale, and of the views:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val isRtl = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL
        Log.d("AppLog", "locale direction:isRTL? $isRtl")
        Log.d("AppLog", "linearLayout direction:${layoutDirectionValueToStr(ViewCompat.getLayoutDirection(linearLayout))}")
        Log.d("AppLog", "textView direction:${layoutDirectionValueToStr(ViewCompat.getLayoutDirection(textView))}")
    }

    fun layoutDirectionValueToStr(layoutDirection: Int): String =
            when (layoutDirection) {
                ViewCompat.LAYOUT_DIRECTION_INHERIT -> "LAYOUT_DIRECTION_INHERIT"
                ViewCompat.LAYOUT_DIRECTION_LOCALE -> "LAYOUT_DIRECTION_LOCALE"
                ViewCompat.LAYOUT_DIRECTION_LTR -> "LAYOUT_DIRECTION_LTR"
                ViewCompat.LAYOUT_DIRECTION_RTL -> "LAYOUT_DIRECTION_RTL"
                else -> "unknown"
            }
}

The result is that even when I switch to RTL locale (Hebrew - עברית), it prints this in logs:

locale direction:isRTL? true 
linearLayout direction:LAYOUT_DIRECTION_LTR 
textView direction:LAYOUT_DIRECTION_LTR

And of course, the textView is aligned to the correct side, according to the current locale:

enter image description here

If it would have worked as I would imagine (meaning LAYOUT_DIRECTION_LOCALE by deafult), this code would have checked if a view is in RTL or not:

fun isRTL(v: View): Boolean = when (ViewCompat.getLayoutDirection(v)) {
    View.LAYOUT_DIRECTION_RTL -> true
    View.LAYOUT_DIRECTION_INHERIT -> isRTL(v.parent as View)
    View.LAYOUT_DIRECTION_LTR -> false
    View.LAYOUT_DIRECTION_LOCALE -> TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL
    else -> false
}

But it can't, because LTR is the default one, and yet it doesn't even matter...

So this code is wrong.

The questions

  1. How could it be that by default, the direction is LTR, yet in practice it gets aligned to the right, in case the locale has changed?

  2. How can I check if a given View's direction would be LTR or RTL , no matter what the developer has set (or not set) for it ?

1条回答
爷、活的狠高调
2楼-- · 2019-06-18 15:47

How could it be that by default, the direction is LTR, yet in practice it gets aligned to the right, in case the locale has changed?

The difference is in time. When the view is created it's assigned a default value until the real value is resolved. Actually there are two values maintained:

  • getLayoutDirection() returns the default LAYOUT_DIRECTION_LTR,
  • getRawLayoutDirection() (hidden API) returns LAYOUT_DIRECTION_INHERIT.

When raw layout direction is LAYOUT_DIRECTION_INHERIT the actual layout direction is resolved as part of the measure call. The view then traverses its parents

  • until it finds a view which has a concrete value set
  • or until it reaches missing view root (the window, or ViewRootImpl).

In the second case, when the view hierarchy is not attached to a window yet, layout direction is not resolved and getLayoutDirection() still returns the default value. This is what happens in your sample code.

When view hierarchy is attached to view root, it is assigned layout direction from the Configuration object. In other words reading resolved layout direction only makes sense after the view hierarchy has been attached to window.

How can I check if a given View's direction would be LTR or RTL , no matter what the developer has set (or not set) for it ?

First check, whether layout direction is resolved. If it is, you may work with the value.

if (ViewCompat.isLayoutDirectionResolved(view)) {
    val rtl = ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL
    // Use the resolved value.
} else {
    // Use one of the other options.
}

Note that the method always returns false below Kitkat.

If layout direction is not resolved, you'll have to delay the check.

Option 1: Post it to the main thread message queue. We're assuming that by the time this runs, the view hierarchy has been attached to window.

view.post {
    val rtl = ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL
    // Use the resolved value.
}

Option 2: Get notified when the view hierarchy is ready to perform drawing. This is available on all API levels.

view.viewTreeObserver.addOnPreDrawListener(
        object : ViewTreeObserver.OnPreDrawListener {
            override fun onPreDraw(): Boolean {
                view.viewTreeObserver.removeOnPreDrawListener(this)
                val rtl = ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL
                // Use the resolved value.
                return true
            }
        })

Note: You actually can subclass any View and override its onAttachedToWindow method, because layout direction is resolved as part of super.onAttachedToWindow() call. Other callbacks (in Activity or OnWindowAttachedListener) do not guarantee that behavior, so don't use them.

More answers to more questions

Where does it get the value of getLayoutDirection and getRawLayoutDirection ?

View.getRawLayoutDirection() (hidden API) returns what you set via View.setLayoutDirection(). By default it's LAYOUT_DIRECTION_INHERIT, which means "inherit layout direction from my parent".

View.getLayoutDirection() returns the resolved layout direction, that's either LOCATION_DIRECTION_LTR (also default, until actually resolved) or LOCATION_DIRECTION_RTL. This method does not return any other values. The return value only makes sense after a measurement happened while the view was part of a view hierarchy that's attached to a view root.

Why is LAYOUT_DIRECTION_LTR the default value ?

Historically Android didn't support right-to-left scripts at all (see here), left-to-right is the most sensible default value.

Would the root of the views return something of the locale?

All views inherit their parent's layout direction by default. So where does the topmost view get the layout direction before it's attached? Nowhere, it can't.

When a view hierarchy is attached to window something like this happens:

final Configuration config = context.getResources().getConfiguration();
final int layoutDirection = config.getLayoutDirection();
rootView.setLayoutDirection(layoutDirection);

Default configuration is set up with system locale and layout direction is taken from that locale. Root view is then set to use that layout direction. Now all its children with LAYOUT_DIRECTION_INHERIT can traverse and be resolved to this absolute value.

Would some modifications of my small function be able to work even without the need to wait for the view to be ready?

As explained in great detail above, sadly, no.

Edit: Your small function would look a little more like this:

@get:RequiresApi(17)
private val getRawLayoutDirectionMethod: Method by lazy(LazyThreadSafetyMode.NONE) {
    // This method didn't exist until API 17. It's hidden API.
    View::class.java.getDeclaredMethod("getRawLayoutDirection")
}

val View.rawLayoutDirection: Int
    @TargetApi(17) get() = when {
        Build.VERSION.SDK_INT >= 17 -> {
            getRawLayoutDirectionMethod.invoke(this) as Int // Use hidden API.
        }
        Build.VERSION.SDK_INT >= 14 -> {
            layoutDirection // Until API 17 this method was hidden and returned raw value.
        }
        else -> ViewCompat.LAYOUT_DIRECTION_LTR // Until API 14 only LTR was a thing.
    }

@Suppress("DEPRECATION")
val Configuration.layoutDirectionCompat: Int
    get() = if (Build.VERSION.SDK_INT >= 17) {
        layoutDirection
    } else {
        TextUtilsCompat.getLayoutDirectionFromLocale(locale)
    }


private fun View.resolveLayoutDirection(): Int {
    val rawLayoutDirection = rawLayoutDirection
    return when (rawLayoutDirection) {
        ViewCompat.LAYOUT_DIRECTION_LTR,
        ViewCompat.LAYOUT_DIRECTION_RTL -> {
            // If it's set to absolute value, return the absolute value.
            rawLayoutDirection
        }
        ViewCompat.LAYOUT_DIRECTION_LOCALE -> {
            // This mimics the behavior of View class.
            TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault())
        }
        ViewCompat.LAYOUT_DIRECTION_INHERIT -> {
            // This mimics the behavior of View and ViewRootImpl classes.
            // Traverse parent views until we find an absolute value or _LOCALE.
            (parent as? View)?.resolveLayoutDirection() ?: run {
                // If we're not attached return the value from Configuration object.
                resources.configuration.layoutDirectionCompat
            }
        }
        else -> throw IllegalStateException()
    }
}

fun View.getRealLayoutDirection(): Int =
        if (ViewCompat.isLayoutDirectionResolved(this)) {
            layoutDirection
        } else {
            resolveLayoutDirection()
        }

Now call View.getRealLayoutDirection() and get the value you were looking for.

Please note that this approach relies heavily on accessing hidden API which is present in AOSP but may not be present in vendor implementations. Test this thoroughly!

查看更多
登录 后发表回答