ChipGroup with draggable Chips - android

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

Related

Using binding to Linear Layout and Setting an Image but ontop of the pervious View (Image)

I'm having an issue with this for two days now... I have a binding and an image to pop when clicking a button.
I want that every time the button is pressed another image will pop either next to the pervious one, or, if there's no space in the linear layout, that it will pop ontop of the previous one.
The idea is to get a card from a card deck and put it ot the hand of the player.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityPlayTableBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
var image = findViewById<ImageView>(R.id.fight_deck)
var i: Int = 1
val deck = Deck()
var deckListOfCards: MutableList<Card> = deck.shuffledDeckOfCard()
val deck_clickable_top = findViewById<ImageButton>(R.id.deck_button)
val imageView = ImageView(this)
val Width = convertDpToPixel(135f, this)
val Height = convertDpToPixel(190f, this)
imageView.layoutParams = LinearLayout.LayoutParams(Width.toInt(), Height.toInt())
var imgResId: Int = 0
deck_clickable_top.setOnClickListener {
if (deckListOfCards.size != 0) {
imgResId = deckListOfCards.get(0).getCardFace()
i += 1
deck.removeCardFromDeck(deckListOfCards.get(0))
}
if (deckListOfCards.size == 0) {
deck_clickable_top.visibility = View.INVISIBLE
deck_clickable_top.layoutParams.height = 0
}
imageView.setImageResource(imgResId)
}
binding.bottomLinearLayout.addView(imageView)
}
I can't figure out how to do it...
Well, LinearLayout doesn't seem like a good choice for this, but you can try something along these lines:
// step 1 - this is the linear layout you'll use as the container
val containerBinding = CustomLinearLayoutContainerBinding.inflate(inflater, container, false)
// step 2 - on some click event you can create a new binding, this should contain you image
val componentBinding = CustomComponentLayoutBinding.inflate(inflater, container, false).apply {
// set the image drawable here, text, any other data in here
}
// step 3 - the root layout of that binding is the linear layout
containerBinding.root.addView(componentBinding.root)
// the following 2 methods of LinearLayout can be very handy
removeAllViews() // to remove all previously added children
invalidate() // to re-render the linear layout
But please note that everything you want to do with those views after you added them will need manual changes and again, this doesn't feel like you should go for it. However, I found this link, check out this implementation, feels like this suits your requirements better.

Adding a Progress Bar to a loading image

I am trying to create a progress bar that will display while an image is downloading from a server. This image is loaded into a custom view. (I need it to be custom because I draw on the image.)
My solution was to add the custom view into the XML under the layout of the fragment, and mark its visibility as Visibility.GONE. This worked in the XML editor, as the progress bar took up the full space. Invisible did not work as it's position was still displayed.
The issue comes when the image path is given to my custom view. It would seem that setting Visibility.GONE on a view means that the view is not measured. But I need the dimensions of the view to measure how large the bitmap should be.
// Create the observer which updates the UI.
val photoObserver = Observer<String?> { photoPath ->
spinner.visibility = View.GONE
thumbnailFrame.visibility = View.VISIBLE
thumbnailFrame.invalidate()
thumbnailFrame.setImage(photoPath)
Looking at the Logs from the custom view, it is calling onMeasured() but it is doing it too late. I need onMeasure() to be called before setImage(). Is there a better way of handling this and if not is there a way to force the code to wait until I know the view has finished its measuring process?
Solved using a basic listener pattern with an anonymous class inline. I'm not sure if there is a better way but this way works just fine. Delay is not much of an issue since the view draws quite fast anyways.
* Set a listener to notify parent fragment/activity when view has been measured
*/
fun setViewReadyListener(thumbnailHolder: ViewReadyListener) {
holder = thumbnailHolder
}
interface ViewReadyListener {
fun onViewSet()
}
private fun notifyViewReadyListener() {
holder?.onViewSet()
}
spinner.visibility = View.INVISIBLE
thumbnailFrame.visibility = View.VISIBLE
//We have to make sure that the view is finished measuring before we attempt to put in a picture
thumbnailFrame.setViewReadyListener(object : ThumbnailFrame.ViewReadyListener {
override fun onViewSet() {
thumbnailFrame.setImage(photoPath)
//If we have a previous saved state, load it here
val radius = viewModel.thumbnailRadius
val xPosit = viewModel.thumbnailXPosit
val yPosit = viewModel.thumbnailYPosit
if (radius != null) {
thumbnailFrame.setRadius(radius)
}
if (xPosit != null) {
thumbnailFrame.setRadius(xPosit)
}
if (yPosit != null) {
thumbnailFrame.setRadius(yPosit)
}
}
})
}

Weighted buttons in vertical linear layout cutting off text if button text line counts vary

TL;DR: Here's the gist of everything that I can think of that's relevant to the issue I'm facing: [GIST LINK]
And here's a picture of the problem
I'm trying to set up a number of buttons that will all grow to the same size as each other by equal weighting in a vertically oriented LinearLayout container.
The problem I'm facing surfaces when the text on these buttons cause a different number of lines per button.
Let's say n is the lowest line count for the buttons and m is the highest line count; any descenders in the text of buttons with line count m are cut off. Refer to the words "qshowing my clipping problem" in the linked screengrab, where all descenders are cut off.
How can I go about fixing this? The clipping gets much worse if I introduce android:lineSpacingExtra to the button style.
If it's relevant, my minimum API is set to 21
I've fixed this using RxJava to set the height programmatically to the correct maximum so that no clipping occurs. If there is a better solution I'll be glad to see it, but this is what is working for me for now:
class MyActivity {
// ...
private val compositeDisposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
setContentView(R.layout.my_activity)
// ...
val container: LinearLayout = findViewById(R.id.container)
val numBtns = getNumBtnsToAdd()
val btnList: MutableList<Button> = mutableListOf()
val margin10 = dpToPx(10f).toInt()
val countDown = CountDownLatch(numBtns)
val desiredLp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0).apply {
gravity = Gravity.CENTER
setMargins(margin10, margin10, margin10, margin10)
}
// Completable will be run once per subscriber and emit a success or error
val layoutCompletable = Completable.fromAction {
countDown.await()
for (btn in btnList) btn.layoutParams = desiredLp
}.subscribeOn(Schedulers.newThread()).observeOn(AndroidSchedulers.mainThread())
compositeDisposable.add(
layoutCompletable.subscribe({
Log.d("MyActivity", "Set LayoutParams on all buttons.")
}, Throwable::printStackTrace)
)
for (i in 0 until numBtns) {
val btn = Button(this, null, 0, R.style.button_style).apply {
layoutParams = LinearLayout.LayoutParams(desiredLp).apply { height = LinearLayout.LayoutParams.WRAP_CONTENTS }
text = if (i == 0) "Button${i+1} with short text"
else "Button${i+1} with text that will span multiple lines showing my clipping problem"
setOnClickListener { doSomething() }
}
val listener = object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
countDown.countDown()
val height = btn.height
if (height > desiredLp.height) desiredLp.height = height
btn.viewTreeObserver.removeOnGlobalLayoutListener(this)
}
}
btn.viewTreeObserver.addOnGlobalLayoutListener(listener)
btnList.add(btn)
container.addView(btn)
}
// ...
}
override fun finish() {
compositeDisposable.clear()
super.finish()
}
// ...
}
My guess, the main cause is buttons have fixed size. More preciously, you use LinearLayout to share available room between buttons via weight attribute. You can see the single line button height is same with 2-lines button height. So 2-lines buttons are forced to clip the text.
According your XML file you want enable the vertical scroll when there is no more room. In this case you don't need to use weight attribute. Just buttons under each other with margins.

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

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!

What exactly does fitsSystemWindows do?

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)

Categories

Resources