I'm struggling to understand the concept of fitsSystemWindows as depending on the view it does different things. According to the official documentation it's a
Boolean internal attribute to adjust view layout based on system windows such as the status bar. If true, adjusts the padding of this view to leave space for the system windows.
Now, checking the View.java class I can see that when set to true, the window insets (status bar, navigation bar...) are applied to the view paddings, which works according to the documentation quoted above. This is the relevant part of the code:
private boolean fitSystemWindowsInt(Rect insets) {
if ((mViewFlags & FITS_SYSTEM_WINDOWS) == FITS_SYSTEM_WINDOWS) {
mUserPaddingStart = UNDEFINED_PADDING;
mUserPaddingEnd = UNDEFINED_PADDING;
Rect localInsets = sThreadLocal.get();
if (localInsets == null) {
localInsets = new Rect();
sThreadLocal.set(localInsets);
}
boolean res = computeFitSystemWindows(insets, localInsets);
mUserPaddingLeftInitial = localInsets.left;
mUserPaddingRightInitial = localInsets.right;
internalSetPadding(localInsets.left, localInsets.top,
localInsets.right, localInsets.bottom);
return res;
}
return false;
}
With the new Material design there are new classes which make extensive use of this flag and this is where the confusion comes. In many sources fitsSystemWindows is mentioned as the flag to set to lay the view behind the system bars. See here.
The documentation in ViewCompat.java for setFitsSystemWindows says:
Sets whether or not this view should account for system screen decorations
such as the status bar and inset its content; that is, controlling whether
the default implementation of {#link View#fitSystemWindows(Rect)} will be
executed. See that method for more details.
According to this, fitsSystemWindows simply means that the function fitsSystemWindows() will be executed? The new Material classes seem to just use this for drawing under the status bar. If we look at DrawerLayout.java's code, we can see this:
if (ViewCompat.getFitsSystemWindows(this)) {
IMPL.configureApplyInsets(this);
mStatusBarBackground = IMPL.getDefaultStatusBarBackground(context);
}
...
public static void configureApplyInsets(View drawerLayout) {
if (drawerLayout instanceof DrawerLayoutImpl) {
drawerLayout.setOnApplyWindowInsetsListener(new InsetsListener());
drawerLayout.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
}
}
And we see the same pattern in the new CoordinatorLayout or AppBarLayout.
Doesn't this work in the exact opposite way as the documentation for fitsSystemWindows? In the last cases, it means draw behind the system bars.
However, if you want a FrameLayout to draw itself behind the status bar, setting fitsSystemWindows to true does not do the trick as the default implementation does what's documented initially. You have to override it and add the same flags as the other mentioned classes. Am I missing something?
System windows are the parts of the screen where the system is drawing
either non-interactive (in the case of the status bar) or interactive
(in the case of the navigation bar) content.
Most of the time, your app won’t need to draw under the status bar or
the navigation bar, but if you do: you need to make sure interactive
elements (like buttons) aren’t hidden underneath them. That’s what the
default behavior of the android:fitsSystemWindows=“true” attribute
gives you: it sets the padding of the View to ensure the contents
don’t overlay the system windows.
https://medium.com/google-developers/why-would-i-want-to-fitssystemwindows-4e26d9ce1eec
it does not draw behind the system bar
it kind of stretches behind the bar to tint it with the same colors it has but the views it contains is padded inside the status bar
if that makes sense
In short, if you're trying to figure out whether to use fitsSystemWindows or not, there's Insetter library by Chris Banes (a developer from the Android team) which offers a better alternative to fitsSystemWindows. For more details let's see the explanation below.
There's a good article published by Android team in 2015 - Why would I want to fitsSystemWindows?. It well explains the default behavior of the attribute and how some layouts like DrawerLayout overrides it.
But, it was 2015. Back in 2017 at droidcon Chris Banes, who works on Android, advised not to use fitSystemWindows attribute unless a container documentation says to use it. And the reason for this is that the default behavior of the flag often doesn't meet your expectations. It's well explained in the video.
But what are these special layouts where you should use fitsSystemWindows? Well, it's DrawerLayout, CoordinatorLayout, AppBarLayout and CollapsingToolbarLayout. These layouts override the default fitsSystemWindows behavior and treat it in a special way, again it's well explained in the video. Such different interpretation of the attribute sometimes leads to a confusion and questions like here. Actually, in another video of droidcon London Chris Banes admits that the decision to overload the default behavior was a mistake (13:10 timestamp of the London conf).
Ok, if fitSystemWindows isn't the ultimate solution, what should be used? In another article from 2019 Chris Banes suggests another solution, a few custom layout attributes based on WindowInsets API. For example, if you want a bottom-right FAB to margin from the navigation bar, you can easily configure it:
<com.google.android.material.floatingactionbutton.FloatingActionButton
app:marginBottomSystemWindowInsets="#{true}"
app:marginRightSystemWindowInsets="#{true}"
... />
The solution uses custom #BindingAdapters, one for paddings and another for margins. The logic is well described in the article I've mentioned above. Some google samples use the solution, for example see Owl android material app, BindingAdapters.kt. I just copy the adapter code here for a reference:
#BindingAdapter(
"paddingLeftSystemWindowInsets",
"paddingTopSystemWindowInsets",
"paddingRightSystemWindowInsets",
"paddingBottomSystemWindowInsets",
requireAll = false
)
fun View.applySystemWindowInsetsPadding(
previousApplyLeft: Boolean,
previousApplyTop: Boolean,
previousApplyRight: Boolean,
previousApplyBottom: Boolean,
applyLeft: Boolean,
applyTop: Boolean,
applyRight: Boolean,
applyBottom: Boolean
) {
if (previousApplyLeft == applyLeft &&
previousApplyTop == applyTop &&
previousApplyRight == applyRight &&
previousApplyBottom == applyBottom
) {
return
}
doOnApplyWindowInsets { view, insets, padding, _ ->
val left = if (applyLeft) insets.systemWindowInsetLeft else 0
val top = if (applyTop) insets.systemWindowInsetTop else 0
val right = if (applyRight) insets.systemWindowInsetRight else 0
val bottom = if (applyBottom) insets.systemWindowInsetBottom else 0
view.setPadding(
padding.left + left,
padding.top + top,
padding.right + right,
padding.bottom + bottom
)
}
}
#BindingAdapter(
"marginLeftSystemWindowInsets",
"marginTopSystemWindowInsets",
"marginRightSystemWindowInsets",
"marginBottomSystemWindowInsets",
requireAll = false
)
fun View.applySystemWindowInsetsMargin(
previousApplyLeft: Boolean,
previousApplyTop: Boolean,
previousApplyRight: Boolean,
previousApplyBottom: Boolean,
applyLeft: Boolean,
applyTop: Boolean,
applyRight: Boolean,
applyBottom: Boolean
) {
if (previousApplyLeft == applyLeft &&
previousApplyTop == applyTop &&
previousApplyRight == applyRight &&
previousApplyBottom == applyBottom
) {
return
}
doOnApplyWindowInsets { view, insets, _, margin ->
val left = if (applyLeft) insets.systemWindowInsetLeft else 0
val top = if (applyTop) insets.systemWindowInsetTop else 0
val right = if (applyRight) insets.systemWindowInsetRight else 0
val bottom = if (applyBottom) insets.systemWindowInsetBottom else 0
view.updateLayoutParams<ViewGroup.MarginLayoutParams> {
leftMargin = margin.left + left
topMargin = margin.top + top
rightMargin = margin.right + right
bottomMargin = margin.bottom + bottom
}
}
}
fun View.doOnApplyWindowInsets(
block: (View, WindowInsets, InitialPadding, InitialMargin) -> Unit
) {
// Create a snapshot of the view's padding & margin states
val initialPadding = recordInitialPaddingForView(this)
val initialMargin = recordInitialMarginForView(this)
// Set an actual OnApplyWindowInsetsListener which proxies to the given
// lambda, also passing in the original padding & margin states
setOnApplyWindowInsetsListener { v, insets ->
block(v, insets, initialPadding, initialMargin)
// Always return the insets, so that children can also use them
insets
}
// request some insets
requestApplyInsetsWhenAttached()
}
class InitialPadding(val left: Int, val top: Int, val right: Int, val bottom: Int)
class InitialMargin(val left: Int, val top: Int, val right: Int, val bottom: Int)
private fun recordInitialPaddingForView(view: View) = InitialPadding(
view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom
)
private fun recordInitialMarginForView(view: View): InitialMargin {
val lp = view.layoutParams as? ViewGroup.MarginLayoutParams
?: throw IllegalArgumentException("Invalid view layout params")
return InitialMargin(lp.leftMargin, lp.topMargin, lp.rightMargin, lp.bottomMargin)
}
fun View.requestApplyInsetsWhenAttached() {
if (isAttachedToWindow) {
// We're already attached, just request as normal
requestApplyInsets()
} else {
// We're not attached to the hierarchy, add a listener to
// request when we are
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
v.requestApplyInsets()
}
override fun onViewDetachedFromWindow(v: View) = Unit
})
}
}
As you can see the realization isn't trivial. As I mentioned before, you're welcome to use Insetter library by Chris Banes which offers the same functionality, see insetter-dbx.
Also note that WindowInsets API is going to change since version 1.5.0 of androidx core library. For example insets.systemWindowInsets becomes insets.getInsets(Type.systemBars() or Type.ime()). See the library documentation and the article for more details.
References:
Why would I want to fitsSystemWindows?
WindowInsets — listeners to layouts
Animating your keyboard (part 1)
Becoming a master window fitter (droidcon London 2017)
Becoming a master window fitter (droidcon NYC 2017)
Related
In my XML I'm just declaring a ChipGroup as follows:
<com.google.android.material.chip.ChipGroup
android:id="#+id/chipGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
And then adding each Chip dynamically (where CustomChipFilterStyle styles them as a "filter" type of Chip):
ChipGroup chipGroup = findViewById(R.id.chipGroup);
for (String name : names) {
Chip chip = new Chip(this, null, R.attr.CustomChipFilterStyle);
chip.setText(name);
chipGroup.addView(chip);
}
In the guidance (see the video clip under "Movable") it suggests that "Input chips can be reordered or moved into other fields":
But I can't see any guidance about how this is done, or find any examples out there. Is it a completely bespoke thing (via View.OnDragListener and chip.setOnDragListener()), or are there utility methods for this as part of the Chip framework? All I really need to be able to do is to reorder Chips within the same ChipGroup. I did start with chip.setOnDragListener() but soon realised I didn't have sufficient knowledge about how to create the necessary animations to nudge and re-order other Chips as the Chip itself is being dragged (and to distinguish between a tap -- to filter -- and a drag)... and I hoped that there might be some out-of-the-box way of doing this with a ChipGroup like is maybe alluded to in the above guidance.
But I can't see any guidance about how [chip reordering within a ChipGroup] is done, or find any examples out there.
It is surprising that there doesn't seem to be an "out-of-the-box" way to reorder chips in a ChipGroup - at least not one that I have found.
All I really need to be able to do is to reorder Chips within the same ChipGroup.
I did start with chip.setOnDragListener() but soon realised I didn't have sufficient knowledge about how to create the necessary animations to nudge and re-order other Chips as the Chip itself is being dragged
The following doesn't really fully answer your question since the answer involves a RecyclerView and not a ChipGroup, but the effect is the same. This solution is based up the ItemTouchHelper Demo
by Paul Burke. I have converted the Java to Kotlin and made some modifications to the code. I have posted a demo repo at ChipReorder The layout manager I use for the RecyclerView is FlexboxLayoutManager.
The demo app relies upon ItemTouchHelper which is a utility class that adds swipe to dismiss and drag & drop support to RecyclerView. If you look at the actual code of ItemTouchHelper, you will get an idea of the underlying complexity of the animation that appears on the screen for a simple drag.
Here is a quick video of chips being dragged around using the demo app.
I believe that any functionality that you may need from ChipGroup can be implemented through RecyclerView or its adapter.
Update: I have added a module to the demo repo called "chipgroupreorder" which reorders chips within a ChipGroup with animation. Although this looks much the same as the RecyclerView solution, it uses a ChipGroup and not a RecyclerView.
The demo uses a View.OnDragListener and relies upon android:animateLayoutChanges="true" that is set for the ChipGroup for the animations.
The selection of which view to shift is rudimentary and can be improved. There are probably other issues that may arise upon further testing.
As you suggested there's no out-of-the-box solution for this. So I've made a sample project to show usage of setOnDragListener & how you can create something like this for yourself.
Note: This is far from being the perfect polished solution that you might expect but I believe it can nudge you in the right direction.
Complete code: https://github.com/mayurgajra/ChipsDragAndDrop
Output:
Pasting code here as well with inline comments:
MainActivity
class MainActivity : AppCompatActivity() {
private val dragMessage = "Chip Added"
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val names = mutableListOf("Name 1", "Name 2", "Name 3")
for (name in names) {
val chip = Chip(this, null, 0)
chip.text = name
binding.chipGroup1.addView(chip)
}
attachChipDragListener()
binding.chipGroup1.setOnDragListener(chipDragListener)
}
private val chipDragListener = View.OnDragListener { view, dragEvent ->
val draggableItem = dragEvent.localState as Chip
when (dragEvent.action) {
DragEvent.ACTION_DRAG_STARTED -> {
true
}
DragEvent.ACTION_DRAG_ENTERED -> {
true
}
DragEvent.ACTION_DRAG_LOCATION -> {
true
}
DragEvent.ACTION_DRAG_EXITED -> {
//when view exits drop-area without dropping set view visibility to VISIBLE
draggableItem.visibility = View.VISIBLE
view.invalidate()
true
}
DragEvent.ACTION_DROP -> {
//on drop event in the target drop area, read the data and
// re-position the view in it's new location
if (dragEvent.clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
val draggedData = dragEvent.clipData.getItemAt(0).text
println("draggedData $draggedData")
}
//on drop event remove the view from parent viewGroup
if (draggableItem.parent != null) {
val parent = draggableItem.parent as ChipGroup
parent.removeView(draggableItem)
}
// get the position to insert at
var pos = -1
for (i in 0 until binding.chipGroup1.childCount) {
val chip = binding.chipGroup1[i] as Chip
val start = chip.x
val end = (chip.x + (chip.width / 2))
if (dragEvent.x in start..end) {
pos = i
break
}
}
//add the view view to a new viewGroup where the view was dropped
if (pos >= 0) {
val dropArea = view as ChipGroup
dropArea.addView(draggableItem, pos)
} else {
val dropArea = view as ChipGroup
dropArea.addView(draggableItem)
}
true
}
DragEvent.ACTION_DRAG_ENDED -> {
draggableItem.visibility = View.VISIBLE
view.invalidate()
true
}
else -> {
false
}
}
}
private fun attachChipDragListener() {
for (i in 0 until binding.chipGroup1.childCount) {
val chip = binding.chipGroup1[i]
if (chip !is Chip)
continue
chip.setOnLongClickListener { view: View ->
// Create a new ClipData.Item with custom text data
val item = ClipData.Item(dragMessage)
// Create a new ClipData using a predefined label, the plain text MIME type, and
// the already-created item. This will create a new ClipDescription object within the
// ClipData, and set its MIME type entry to "text/plain"
val dataToDrag = ClipData(
dragMessage,
arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN),
item
)
// Instantiates the drag shadow builder.
val chipShadow = ChipDragShadowBuilder(view)
// Starts the drag
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
//support pre-Nougat versions
#Suppress("DEPRECATION")
view.startDrag(dataToDrag, chipShadow, view, 0)
} else {
//supports Nougat and beyond
view.startDragAndDrop(dataToDrag, chipShadow, view, 0)
}
view.visibility = View.INVISIBLE
true
}
}
}
}
ChipDragShadowBuilder:
class ChipDragShadowBuilder(view: View) : View.DragShadowBuilder(view) {
//set shadow to be the drawable
private val shadow = ResourcesCompat.getDrawable(
view.context.resources,
R.drawable.shadow_bg,
view.context.theme
)
// Defines a callback that sends the drag shadow dimensions and touch point back to the
// system.
override fun onProvideShadowMetrics(size: Point, touch: Point) {
// Sets the width of the shadow to full width of the original View
val width: Int = view.width
// Sets the height of the shadow to full height of the original View
val height: Int = view.height
// The drag shadow is a Drawable. This sets its dimensions to be the same as the
// Canvas that the system will provide. As a result, the drag shadow will fill the
// Canvas.
shadow?.setBounds(0, 0, width, height)
// Sets the size parameter's width and height values. These get back to the system
// through the size parameter.
size.set(width, height)
// Sets the touch point's position to be in the middle of the drag shadow
touch.set(width / 2, height / 2)
}
// Defines a callback that draws the drag shadow in a Canvas that the system constructs
// from the dimensions passed in onProvideShadowMetrics().
override fun onDrawShadow(canvas: Canvas) {
// Draws the Drawable in the Canvas passed in from the system.
shadow?.draw(canvas)
}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
android:background="#color/white"
android:orientation="vertical"
tools:context=".MainActivity">
<com.google.android.material.chip.ChipGroup
android:id="#+id/chipGroup1"
android:layout_width="match_parent"
android:layout_height="56dp"
app:singleSelection="true">
</com.google.android.material.chip.ChipGroup>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#555" />
</LinearLayout>
For understanding how drag works in detail. I would suggest you read: https://www.raywenderlich.com/24508555-android-drag-and-drop-tutorial-moving-views-and-data
I am facing an issue with the full-screen immersive mode in Android 11. My Main activity layout file look something like this,
<DrawerLayout .... android:fitsSystemWindows="true">
<CoordinatorLayout>
<AppBarLayout>
<FragmentContainerView>
I am trying to show a full-screen mode from my Fragment.
Pre Android 11, I used to call the below function from my Fragment's onCreate view
fun hideStatusBar (activity: AppCompatActivity?) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) }
I replaced that with,
fun hideStatusBar(activity: AppCompatActivity?) {
#Suppress("DEPRECATION")
if (isAtLeastAndroid11()) {
val controller = activity?.window?.insetsController
controller?.hide(WindowInsets.Type.statusBars())
} else {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
}
This removes the status bar as intended but leaves a blank space at the top of the screen in the status bar area
With status bar:
Status bar removed:
I tried to measure the Window Insets inside my fragment and adjust the height of my fragment container
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
if(isAtLeastAndroid11()){
WindowCompat.setDecorFitsSystemWindows(requireActivity().window, false)
setUiWindowInsets()
}
}
private fun setUiWindowInsets() {
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
posTop = insets.getInsets(WindowInsetsCompat.Type.systemBars()).top
posBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
rootView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
updateMargins(
top = posTop,
bottom = posBottom)
}
insets
}
}
But my ViewCompat.setOnApplyWindowInsetsListener is never called. As per this article, Coordinator Layout consumes onApplyWindowInsets callbacks and the child won't get any callbacks. rootView is the root view of my Fragment (a relative layout) which was placed in FragmentContainer in my layout hierarchy.
Comparison between Android 10 and 11
My Question:
How should I get the call to my setOnApplyWindowInsetsListener method in fragment?
How should I let my coordinator layout know to occupy full screen when status bar is hidden?
References:
Coordinator layout consumes callbacks:
setOnApplyWindowInsetsListener never called
https://medium.com/androiddevelopers/why-would-i-want-to-fitssystemwindows-4e26d9ce1eec
https://newbedev.com/fitssystemwindows-effect-gone-for-fragments-added-via-fragmenttransaction
The problem is from the App bar. even though it is full screen it will try to prevent content from colliding with status bar even if it is hidden. this is a new implementation also to prevent the phone notch from covering content.
You should remove the app bar if you want to utilize the whole screen. if really need a tooltip for actions use a bottom bar.
You may have to set fitsSystemWindows flag off (where window is Window instance):
window.setDecorFitsSystemWindows(false);
So I do like this:
private void hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
windowInsetsController.hide(WindowInsets.Type.statusBars());
window.setDecorFitsSystemWindows(false);
}
}
Of course, you also need to set the flag on when showing status bar:
private void showSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
windowInsetsController.show(WindowInsets.Type.statusBars());
window.setDecorFitsSystemWindows(true);
}
}
FYI, you may have to trigger hideSystemUI or showSystemUI in Activity.onWindowFocusChanged.
#Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
if (isImmersive) {
hideSystemUI();
} else {
showSystemUI();
}
}
}
(above is in Java. Please translate it into Kotlin)
I currently have a bottom navigation bar inside a Coordinator layout, which I added a HideBottomViewOnScrollBehaviour to. Some screens require the navigation bar to be hidden, which I can achieve by calling the slideUp / slideDown methods from the behaviour object of BottomNavigationBar layout params.
The issue is, even if i'm hiding it programatically, you can reveal it by simply scrolling up again.
I didn't find any solutions, i was thinking there will be something like disabling the behaviour and enabling it on certain screens, but that's not a thing.
Any solutions?
Thanks!
Here are methods to disable/enable scrolling behaviour:
fun enableLayoutBehaviour() {
val params = navView?.layoutParams as CoordinatorLayout.LayoutParams
if (params.behavior == null) {
params.behavior = HideBottomViewOnScrollBehavior<View>()
}
navView?.let {
(params.behavior as HideBottomViewOnScrollBehavior).slideUp(it)
}
}
fun disableLayoutBehaviour() {
val params = navView?.layoutParams as CoordinatorLayout.LayoutParams
navView?.let {
(params.behavior as HideBottomViewOnScrollBehavior).slideDown(it)
}
params.behavior = null
}
You can also just replace NestedScrollView with regular ScrollView on dedicated tabs to disable bottom bar from scrolling.
Currently my popup is overlapping other views. setElevation(0) changes nothing. setOverlapAnchor(false) and setAttachedInDecor(true) also don't help much. Below is the code I have used. I need the popup to be located under navigation drawer
private fun showPopup(anchorView: View) {
PopupWindow(
LayoutInflater.from(activity).inflate(
R.layout.popup_layout,
null
),
100,
100,
false
)
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
view?.elevation = 0f
contentView.elevation = 0f
elevation = 0f
}
isTouchable = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
isAttachedInDecor = true
}
PopupWindowCompat.setOverlapAnchor(this, false)
PopupWindowCompat.showAsDropDown(this, anchorView, 0, 0, Gravity.NO_GRAVITY)
}
}
PopupWindow is a window. Your navigation drawer is located on another window with its own view hierarchy.
Its like this:
-- activity
---- window1
------ viewhierarchy
--------NavigationDrawer
---- window2
------ popup
What you want is not possible using PopupWindow.
One possible work-around is to hide & show the popup when navigation is opened and closed. Here's the callback:
https://developer.android.com/reference/android/support/v4/widget/DrawerLayout.DrawerListener.html#ondrawerstatechanged
Or you may add a view as popup by yourself and take care of the positioning and gravity.
Last but not least, check out these libraries as they may have what you want. They work with view so you may manage it the way you want.
https://github.com/sephiroth74/android-target-tooltip
https://github.com/tomergoldst/tooltips
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:
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
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?
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 ?
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!