How to detemine the current direction of a View (RTL/LTR)?
Asked Answered
B

1

12

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 ?

Bathysphere answered 16/1, 2018 at 8:51 Comment(10)
Can you try with getResources().getConfiguration().locale in place of Locale.getDefault()? Does that return RTL correctly?Coper
@EugenPechanec The question was of checking how a given view would behave: will it be with direction of RTL or of LTR ? As I've written, I already know how to check if the current locale is RTL or not (using TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL ) . I've updated the question to make it clearerBathysphere
Oh right, sorry, I missed that. Do you have android:supportsRtl="true" on your application tag in manifest? If you look at the source of View.resolveLayoutDirection it only works in such case.Coper
Yes. it's enabled by default, when you create a new project. If it was not written, the TextView wouldn't have changed its location to be on the right, when I change locale to RTL.Bathysphere
Layout direction is resolved in View.onMeasure. When you move logging to Activity.onResume, does it print correct values?Coper
@EugenPechanec Same result for onResume. However, if I use Handler().postDelayed(...,1000) , it works. Is there any way to check it out as soon as possible? Even on onCreate ? And, why is it LTR by default? This value is supposed to be set if it should be by force, no?Bathysphere
Nice, I suspect calling View.resolveRtlPropertiesIfNeeded through reflection before View.getLayoutDirection should do the trick.Coper
Can you please show how? Maybe it's possible even by extending from the view ?Bathysphere
Alright, it didn't work. Turns out that it only works after View.isAttachedToWindow returns true. View.post (instead of postDelayed) is good enough. I'll start writing an answer.Coper
Is it possible without any kind of delay, meaning right away? Like measuring is possible in special cases ... ?Bathysphere
C
15

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!

Coper answered 16/1, 2018 at 13:27 Comment(10)
Where does it get the value of getLayoutDirection and getRawLayoutDirection ? Why is LAYOUT_DIRECTION_LTR the default value ? Would the root of the views return something of the locale? Would some modifications of my small function be able to work even without the need to wait for the view to be ready ?Bathysphere
@androiddeveloper I hope the update answers all your questions. I can really recommend digging into Android source code and understanding the connections for yourself.Coper
Wow in Kotlin reflection looks even worse than on Java. Anyway, isn't there a way to get the direction, directly, without reflection? Maybe like what we can do to get the size of a view by measuring it (possible in some cases) ?Bathysphere
Actually, reflection is not even four lines out of all this, much cleaner than Java. getLayoutDirection has a rough history and that results in this mess. ||| Again, no, until the view is attached, there's no public API that will give you resolved layout direction. ||| I never asked, what's the use case for this? If the app is under your control and you never set layout direction manually, it's safe to assume it's inferred from locale.Coper
For this special class that's used for a RecyclerView's snapping : https://mcmap.net/q/448598/-how-to-snap-recyclerview-items-so-that-every-x-items-would-be-considered-like-a-single-unit-to-snap-to . It needs to determine how to work based on the correct direction of the RecyclerView. Maybe it's possible to check the direction later, instead of right away, there. The creator of the class used the getLayoutDirection , and I told him that it's incorrect because when I change the locale of the device, it becomes unusable. However, even with my solution this is technically incorrect, because it doesn't take the developer setting into account.Bathysphere
Anyway, I've marked this answer as accepted one, even though I wish there could be a better way.Bathysphere
@androiddeveloper I'll let you know if I dig up something more on SnapToBlock. As far as I can see it resolves layout direction using the Option 2 I mentioned earlier.Coper
I think it should just check for layout direction when the view is ready to tell it. Currently it checks it right away, so it will always get LTR, even if the locale is RTL.Bathysphere
@androiddeveloper Option 2 uses predraw listener and checks layout direction in the callback. At that point the view is ready to draw - it's been attached, measured and even laid out. At that point the view is ready to tell it and will return correct resolved layout direction.Coper
OK I just wanted to know what's the best for the SnapToBlock issue. There I've chosen to check the layoutDirection only when it's being used, and so far it seems like it's working perfectly. You can see the Kotlin code of it in the question itself (didn't want to create a new answer that's almost identical to what the person did).Bathysphere

© 2022 - 2024 — McMap. All rights reserved.