2

I've almost got this working by following the suggestions in this question

Android percent screen width in RecyclerView item

However that sets a height for all views, same height regardless of the content. In my case I just want to limit how tall an item inside the RecyclerView can be related to its height.

In my RV I have items that have different heights, with some of those items capable of being even taller than the RV.

My goal is to tell that the item can be at max 60% of the RecyclerView height and so far the only way I found to achieve this is to set the max height manually whenever a new item is bound to the view holder using this logic:

constraintSet.constrainMaxHeight(viewId, (int) (recyclerView.getMeasuredHeight() * 0.6));

Where recyclerView is a reference to the hosting RecyclerView that I pass to the adapter using the adapter's method onAttachedToRecyclerView and the viewId is the main view inside the RV item ConstraintLayout root view with which I control the max height it can take.

Althought this works for what I need, I'd like to find a simpler/more optimized solution for this, similar to this one in terms of simplicity: https://stackoverflow.com/a/51224889/524695

And that means any use of ViewTreeObserver is excluded.

Any ideas?

Shadow
  • 4,168
  • 5
  • 41
  • 72
  • Why doesn't Ben P's [answer](https://stackoverflow.com/a/51202132/6287910) work for you? The code would run when the view holder is created and not each time it is re-bound which is simpler. – Cheticamp Nov 11 '20 at 16:24
  • @Cheticamp I did mention in the second paragraph the reason for why it does not work for me; his answer sets that same fixed height for all items when I just want to set a max height. i.e. I don't want all items to have the same height, they can vary, I just want to limit how much they can vary by setting the max height. – Shadow Nov 11 '20 at 16:31
  • I see and there is no "max height" setting for items of _RecyclerView_ which is what I was thinking. Too bad. You might be able to do something with [RecyclerView.LayoutManager#measureChild](https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.LayoutManager#measureChild(android.view.View,%20int,%20int)) to enforce a maximum height. Not sure if that would be simpler, though. – Cheticamp Nov 11 '20 at 19:13

2 Answers2

3

Overwriting measureChildWithMargins is the way to go, however to get it actually working you need custom LayoutParams so you can "carry over" data from your adapter or straight up inflate your desired percentages out of XML.

This layout manager will handle it:

open class PercentLinearLayoutManager : LinearLayoutManager {
    constructor(context: Context?) : super(context)
    constructor(context: Context?, orientation: Int, reverseLayout: Boolean) : super(context, orientation, reverseLayout)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)

    // where we actually force view to be measured based on custom layout params
    override fun measureChildWithMargins(child: View, widthUsed: Int, heightUsed: Int) {
        val pp = child.layoutParams as PercentParams
        if (pp.maxPercentHeight <= 0.0f)
            super.measureChildWithMargins(child, widthUsed, heightUsed)
        else {
            val widthSpec = getChildMeasureSpec(width, widthMode,
                    paddingLeft + paddingRight + pp.leftMargin + pp.rightMargin + widthUsed, pp.width,
                    canScrollHorizontally())
            val maxHeight = (height * pp.maxPercentHeight).toInt()
            val heightSpec = when (pp.height) {
                ViewGroup.LayoutParams.MATCH_PARENT -> View.MeasureSpec.makeMeasureSpec(maxHeight, View.MeasureSpec.EXACTLY)
                ViewGroup.LayoutParams.WRAP_CONTENT -> View.MeasureSpec.makeMeasureSpec(maxHeight, View.MeasureSpec.AT_MOST)
                else -> View.MeasureSpec.makeMeasureSpec(min(pp.height, maxHeight), View.MeasureSpec.AT_MOST)
            }
            child.measure(widthSpec, heightSpec)
        }
    }

    // everything below is needed to generate custom params
    override fun checkLayoutParams(lp: RecyclerView.LayoutParams) = lp is PercentParams

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return PercentParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    }

    override fun generateLayoutParams(lp: ViewGroup.LayoutParams): RecyclerView.LayoutParams {
        return PercentParams(lp)
    }

    override fun generateLayoutParams(c: Context, attrs: AttributeSet): RecyclerView.LayoutParams {
        return PercentParams(c, attrs)
    }

    class PercentParams : RecyclerView.LayoutParams {
        /** Max percent height of recyclerview this item can have. If height is `match_parent` this size is enforced. */
        var maxPercentHeight = 0.0f

        constructor(c: Context, attrs: AttributeSet) : super(c, attrs){
            val t = c.obtainStyledAttributes(attrs, R.styleable.PercentLinearLayoutManager_Layout)
            maxPercentHeight = t.getFloat(R.styleable.PercentLinearLayoutManager_Layout_maxPercentHeight, 0f)
            t.recycle()
        }
        constructor(width: Int, height: Int) : super(width, height)
        constructor(source: MarginLayoutParams?) : super(source)
        constructor(source: ViewGroup.LayoutParams?) : super(source)
        constructor(source: RecyclerView.LayoutParams?) : super(source)
    }
}

To handle inflation from XML custom attribute is needed, add it to values/attrs.xml:

<declare-styleable name="PercentLinearLayoutManager_Layout">
    <attr name="maxPercentHeight" format="float"/>
</declare-styleable>

Then you have two options:

1 - alter custom params inside onBindViewHolder to have per-item control:

val lp = holder.itemView.layoutParams as PercentLinearLayoutManager.PercentParams
if(modifyThisViewHolderHeight) {
    lp.maxPercentHeight = 0.6f
} else {
    lp.maxPercentHeight = 0.0f // clear percentage in case viewholder is reused
}

2 - use custom attribute inside your viewholder layout:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:maxPercentHeight ="0.6">

    <!-- rest of layout-->
</FrameLayout>
Pawel
  • 15,548
  • 3
  • 36
  • 36
  • I am not sure if you actually read what I explained in my question or if you simply ignored it, but I made it perfectly clear that I'd like to find a simple/more optimized solution than the one I am already using. Your suggestion is the direct opposite of that. – Shadow Nov 11 '20 at 22:18
  • @Shadow default layout managers do not support percent sizes, that's why you have to write custom one to handle it properly. Any solution that relies on snapshotting recyclerViews height while binding viewholder is flawed since it won't properly handle the case of RecyclerView itself being resized. – Pawel Nov 12 '20 at 00:42
  • I understand what you are saying, but as I mentioned it is the direct opposite of what I asked for. However, I am sure this will be very handy for someone that wants this level of functionality. Could you edit your answer and mention that in the beginning so that I can upvote it? – Shadow Nov 12 '20 at 01:43
  • @Pawel please tell me if(modifyThisViewHolderHeight) { what i write here "modifythisViewHolderHeight" – Meerz Mar 26 '21 at 19:45
  • @Meerz that's just pseudocode for any condition you might want to check in case you alter percentage on per-item basis. If you want to apply it to all items you can just use XML attribute. – Pawel Mar 26 '21 at 20:47
1

I'd recommend the same approximate strategy I used in my linked answer: set the max height when you create the ViewHolder. However, generic View does not support the maxHeight property, so we'll leverage ConstraintLayout to achieve what we want.

If the root view for your item views is already a ConstraintLayout, great, otherwise you can wrap whatever you do have inside it. In this contrived example, I'm using this layout (the FrameLayout is using 0dp width and wrap_content height in the XML):

<androidx.constraintlayout.widget.ConstraintLayout ...>
    <FrameLayout ...>
        <TextView .../>
    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

At creation time, I set the max height of the FrameLayout to be 60% of the parent's height:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
    val inflater = LayoutInflater.from(parent.context)
    val itemView = inflater.inflate(R.layout.recycler_item, parent, false)
    val holder = MyViewHolder(itemView)

    val params = holder.frame.layoutParams as ConstraintLayout.LayoutParams
    params.matchConstraintMaxHeight = (parent.height * 0.6).toInt()
    holder.frame.layoutParams = params

    return holder
}
Ben P.
  • 52,661
  • 6
  • 95
  • 123
  • This feels like what I am already doing, but with an extra layer inside the root view of the item, but it might work better than the way I'm implementing it at the moment, and "better" is what I want. I'll try this one out and see, thanks! – Shadow Nov 11 '20 at 22:42
  • Yeah, it's very similar in terms of overall strategy. It just moves from onBind to onCreate, basically. – Ben P. Nov 11 '20 at 22:46
  • Note that some views (like `TextView`) support a max height natively; in these cases you could avoid the `ConstraintLayout` wrapper and just call `setMaxHeight()` in `onCreateViewHolder()`. – Ben P. Nov 11 '20 at 22:47
  • Understood, fortunately all the root views of the items I am feeding are `ConstraintLayout` so there is no issue at the moment. – Shadow Nov 11 '20 at 22:49