Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Making a ListAdapter-recycleable Resizable View

I'm working on creating a custom view that will have an expanded and condensed state -- in the condensed state, it will just show a label and an icon, and in the expanded state, it will show a message below that. Here is a screenshot of how it works so far:

screenshot

The View itself retains size values for the condensed and expanded states once measured, so it's simple to animate between the two states, and when using the view in normal practice (e.g. in a LinearLayout) everything works as intended. The change to the view size is done by calling getLayoutParams().height = newHeight; requestLayout();

However, when using it in a ListView, the view is recycled and maintains its previous height. So if the view was expanded when it was hidden, it will show as expanded when it is recycled for the next list item. It does not seem to receive another layout pass, even if I request a layout in the ListAdapter. I considered using a recycler with two different view types (expanded and condensed), but the sizes will vary depending on the size of the message. Is there an event I can listen for when the view is reattached in the ListView? Or do you have another suggestion of how to handle this?

EDIT: This is how I'm determining the expanded and condensed heights for the view:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    super.onLayout(changed, l, t, r, b);
    if(r - l > 0 && b - t > 0 && dimensionsDirty) {
        int widthSpec = MeasureSpec.makeMeasureSpec(r - l, MeasureSpec.EXACTLY);
        messageView.setVisibility(GONE);
        measure(widthSpec, MeasureSpec.UNSPECIFIED);
        condensedHeight = getMeasuredHeight();

        messageView.setVisibility(VISIBLE);
        measure(widthSpec, MeasureSpec.UNSPECIFIED);
        expandedHeight = getMeasuredHeight();

        dimensionsDirty = false;
    }
}
like image 325
Kevin Coppock Avatar asked Jan 02 '13 19:01

Kevin Coppock


1 Answers

EDIT: Fixed order of parameters for both calls to makeMeasureSpec. Oddly, it worked the incorrect way that I had it, so I almost wonder if I'm doing something redundant. Either way, just wanted to point it out -- the project to download below doesn't have these corrections.

Okay, so it was really bothering me that I couldn't figure this out, so I decided to get more familiar with the layout and measurement system, and here's the solution that I've come up with.

  1. A custom ViewGroup extending FrameLayout that hosts a single direct child (like ScrollView.)
  2. A custom ListAdapter that handles tracking the expanded/collapsed state of each list item.
  3. A custom OnItemClickListener to handle requests to animate between collapsed and expanded states.

ResizeLayout Screenshot

I'd like to share this code in case anyone else finds it useful. It should be fairly flexible, but I have no doubt there are bugs and things that could be improved. For one, I had issues programmatically scrolling the ListView (there doesn't seem to be a way to actually scroll the contents rather than just the view) so I used smoothScrollToPosition(int) for each change to the view size. This has a hardcoded 400ms duration which is unnecessary, so in the future I might try to write my own version with a duration of 0 (i.e. scrollToPosition(int)).

The general use is as follows:

  1. Your list item XML should have your ResizeLayout as the root of the hierarchy, and from there you can build any layout structure you want. Basically just wrap your normal list item layout in a ResizeLayout tag.

  2. In your layout, you should have one view with the id collapse_to. This is the view that the layout will wrap to (i.e. what view determines the collapsed height).

  3. Important things to do if you're recycling through a list adapter:

    • ALWAYS call reuse() when you retrieve a recycled view (e.g. convertView)
    • ALWAYS call setIsExpanded(boolean) before returning the recycled view; otherwise it will retain the state it was in before it was recycled

I may eventually throw this into a git repo, but for now here's the code:

ResizeLayout.java

This is the bulk of the code. I'll also include my Activity and Adapter that I used for testing further down. They're quite generic, but they illustrate the use effectively.

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.*;
import android.widget.FrameLayout;

/*
 * ResizeLayout
 * 
 * Custom ViewGroup that allows you to specify a view in the child hierarchy to wrap to, and 
 * allows for the view to be expanded to the full size of the content.
 * 
 * Author:  Kevin Coppock
 * Date:    2013/03/02
 */

public class ResizeLayout extends FrameLayout {
    private static final int PX_PER_SEC = 900; //Pixels per Second to animate layout changes
    private final LayoutAnimation animation = new LayoutAnimation();
    private final int wrapSpec = MeasureSpec.makeMeasureSpec(LayoutParams.WRAP_CONTENT, MeasureSpec.UNSPECIFIED);

    private int collapsedHeight = 0;
    private int expandedHeight = 0;
    private boolean contentsChanged = true;
    private State state = State.COLLAPSED;

    private OnLayoutChangedListener listener;

    public ResizeLayout(Context context) { super(context); }
    public ResizeLayout(Context context, AttributeSet attrs) { super(context, attrs); }
    public ResizeLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        if(getChildCount() > 0) {
            View child = getChildAt(0);
            child.layout(0, 0, child.getMeasuredWidth(), child.getMeasuredHeight());
        }

        //If the layout parameters have changed and the view is animating, notify listeners
        if(changed && animation.isAnimating()) {
            switch(state) {
                case COLLAPSED: fireOnLayoutCollapsing(left, top, right, bottom); break;
                case EXPANDED:  fireOnLayoutExpanding(left, top, right, bottom); break;
            }
        }
    }

    /**
     * Reset the internal state of the view to defaults. This should be called any time you change the contents
     * of this ResizeLayout (e.g. recycling through a ListAdapter)
     */
    public void reuse() {
        collapsedHeight = expandedHeight = 0;
        contentsChanged = true;
        state = State.COLLAPSED;
        requestLayout();
    }

    /**
     * Set the state of the view. This should ONLY be called after a call to reuse() as it does not animate
     * the view; it simply sets the internal state. An example of usage is in a ListAdapter -- if the view is
     * recycled, it may be in the incorrect state, so it should be set here to the correct state before layout.
     * @param isExpanded whether or not the view should be in the expanded state
     */
    public void setIsExpanded(boolean isExpanded) {
        state = isExpanded ? State.EXPANDED : State.COLLAPSED;
    }

    /**
     * Animates the ResizeLayout between COLLAPSED and EXPANDED states, only if it is not currently animating.
     */
    public void animateToNextState() {
        if(!animation.isAnimating()) {
            animation.reuse(state.getStartHeight(this), state.getEndHeight(this));
            state = state.next();
            startAnimation(animation);
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        if(getChildCount() < 1) { //ResizeLayout has no child; default to spec, or padding if unspecified
            setMeasuredDimension(
                widthMode ==  MeasureSpec.UNSPECIFIED ? getPaddingLeft() + getPaddingRight() : width,
                heightMode == MeasureSpec.UNSPECIFIED ? getPaddingTop() + getPaddingBottom() : height
            );
            return;
        }

        View child = getChildAt(0); //Get the only child of the ResizeLayout

        if(contentsChanged) { //If the contents of the view have changed (first run, or after reset from reuse())
            contentsChanged = false;
            updateMeasurementsForChild(child, widthMeasureSpec, heightMeasureSpec);
            return;
        }

        //This state occurs on the second run. The child might be wrap_content, so the MeasureSpec will be unspecified.
        //Skip measuring the child and just accept the measurements from the first run.
        if(heightMode == MeasureSpec.UNSPECIFIED) {
            setMeasuredDimension(getWidth(), getHeight());
        } else {
            //Likely in mid-animation; we have a fixed-height from the MeasureSpec so use it
            child.measure(widthMeasureSpec, heightMeasureSpec);
            setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight());
        }
    }

    /**
     * Sets the measured dimension for this ResizeLayout, getting the initial measurements
     * for the condensed and expanded heights from the child view.
     * @param child the child view of this ResizeLayout
     * @param widthSpec the width MeasureSpec from onMeasure()
     * @param heightSpec the height MeasureSpec from onMeasure()
     */
    private void updateMeasurementsForChild(View child, int widthSpec, int heightSpec) {
        child.measure(widthSpec, wrapSpec); //Measure the child using WRAP_CONTENT for the height

        //Get the View that has been selected as the "collapse to" view (ID = R.id.collapse_to)
        View viewToCollapseTo = child.findViewById(R.id.collapse_to);

        if(viewToCollapseTo != null) {
            //The collapsed height should be the height of the collapseTo view + any top or bottom padding
            collapsedHeight = viewToCollapseTo.getMeasuredHeight() + child.getPaddingTop() + child.getPaddingBottom();

            //The expanded height is simply the full height of the child (measured with WRAP_CONTENT)
            expandedHeight = child.getMeasuredHeight();

            //Re-Measure the child to reflect the state of the view (COLLAPSED or EXPANDED)
            int newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(state.getStartHeight(this), MeasureSpec.EXACTLY);
            child.measure(widthSpec, newHeightMeasureSpec);
        }
        setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight());
    }

    @Override
    public void addView(View child) {
        if(getChildCount() > 0) {
            throw new IllegalArgumentException("ResizeLayout can host only one direct child.");
        } else {
            super.addView(child);
        }
    }

    @Override
    public void addView(View child, int index, ViewGroup.LayoutParams params) {
        if(getChildCount() > 0) {
            throw new IllegalArgumentException("ResizeLayout can host only one direct child.");
        } else {
            super.addView(child, index, params);
        }
    }

    @Override
    public void addView(View child, ViewGroup.LayoutParams params) {
        if(getChildCount() > 0) {
            throw new IllegalArgumentException("ResizeLayout can host only one direct child.");
        } else {
            super.addView(child, params);
        }
    }

    @Override
    public void addView(View child, int width, int height) {
        if(getChildCount() > 0) {
            throw new IllegalArgumentException("ResizeLayout can host only one direct child.");
        } else {
            super.addView(child, width, height);
        }
    }

    /**
     * Handles animating the view between its expanded and collapsed states by adjusting the
     * layout parameters of the containing object and requesting a layout pass.
     */
    private class LayoutAnimation extends Animation implements Animation.AnimationListener {
        private int startHeight = 0, deltaHeight = 0;
        private boolean isAnimating = false;

        /**
         * Just a default interpolator and friction I think feels nice; can be changed.
         */
        public LayoutAnimation() {
            setInterpolator(new DecelerateInterpolator(2.2f));
            setAnimationListener(this);
        }

        /**
         * Sets the duration of the animation to a duration matching the specified value in
         * Pixels per Second (PPS). For example, if the view animation is 60 pixels, then a PPS of 60
         * would set a duration of 1000ms (i.e. duration = (delta / pps) * 1000). PPS is used rather
         * than a fixed time so that the animation speed is consistent regardless of the contents
         * of the view.
         * @param pps the number of pixels per second to resize the layout by
         */
        private void setDurationPixelsPerSecond(int pps) {
            setDuration((int) (((float) Math.abs(deltaHeight) / pps) * 1000));
        }

        /**
         * Allows reuse of a single LayoutAnimation object. Call this before starting the animation
         * to restart the animation and set the new parameters
         * @param startHeight the height from which the animation should begin
         * @param endHeight the height at which the animation should end
         */
        public void reuse(int startHeight, int endHeight) {
            reset();
            setStartTime(0);
            this.startHeight = startHeight;
            this.deltaHeight = endHeight - startHeight;
            setDurationPixelsPerSecond(PX_PER_SEC);
        }

        /**
         * Applies the height transformation to this containing ResizeLayout
         * @param interpolatedTime the time (0.0 - 1.0) interpolated based on the set interpolator
         * @param t the transformation associated with the animation -- not used here
         */
        @Override
        protected void applyTransformation(float interpolatedTime, Transformation t) {
            getLayoutParams().height = startHeight + (int)(deltaHeight * interpolatedTime);
            requestLayout();
        }

        public boolean isAnimating() {
            return isAnimating;
        }

        @Override
        public void onAnimationStart(Animation animation) {
            isAnimating = true;
        }

        @Override
        public void onAnimationEnd(Animation animation) {
            isAnimating = false;
        }

        @Override
        public void onAnimationRepeat(Animation animation) {
            /*Not implemented*/
        }
    }

    /**
     * Interface to listen for layout changes during an animation
     */
    public interface OnLayoutChangedListener {
        public void onLayoutExpanding(int l, int t, int r, int b);
        public void onLayoutCollapsing(int l, int t, int r, int b);
    }

    /**
     * Sets a listener for changes to this view's layout
     * @param listener the listener for layout changes
     */
    public void setOnBoundsChangedListener(OnLayoutChangedListener listener) {
        this.listener = listener;
    }

    private void fireOnLayoutExpanding(int l, int t, int r, int b) {
        if(listener != null) listener.onLayoutExpanding(l, t, r, b);
    }

    private void fireOnLayoutCollapsing(int l, int t, int r, int b) {
        if(listener != null) listener.onLayoutCollapsing(l, t, r, b);
    }

    protected enum State {
        COLLAPSED{
            @Override
            public State next() {
                return EXPANDED;
            }

            @Override
            public int getEndHeight(ResizeLayout view) {
                return view.expandedHeight;
            }

            @Override
            public int getStartHeight(ResizeLayout view) {
                return view.collapsedHeight;
            }
        },
        EXPANDED{
            @Override
            public State next() {
                return COLLAPSED;
            }

            @Override
            public int getEndHeight(ResizeLayout view) {
                return view.collapsedHeight;
            }

            @Override
            public int getStartHeight(ResizeLayout view) {
                return view.expandedHeight;
            }
        };

        public abstract State next();
        public abstract int getStartHeight(ResizeLayout view);
        public abstract int getEndHeight(ResizeLayout view);
    }
}

MyActivity.java

Just a simple ListActivity that I used for the purposes of this example. main.xml is just the generic LinearLayout with ListView child XML for a ListActivity.

import android.app.ListActivity;
import android.content.Context;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.BaseAdapter;
import android.widget.TextView;

import java.util.HashSet;
import java.util.Set;

public class MyActivity extends ListActivity implements ResizeLayout.OnLayoutChangedListener, AdapterView.OnItemClickListener {
    private MyAdapter myAdapter;
    private int clickedItemPosition;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        myAdapter = new MyAdapter(this);
        setListAdapter(myAdapter);
        getListView().setOnItemClickListener(this);
        getListView().setSelector(new ColorDrawable(Color.TRANSPARENT));
    }

    @Override
    public void onLayoutExpanding(int l, int t, int r, int b) {
        //Keep the clicked view fully visible if it's expanding
        getListView().smoothScrollToPosition(clickedItemPosition);
    }

    @Override
    public void onLayoutCollapsing(int l, int t, int r, int b) {
        //Not handled currently
    }

    @Override
    public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
        clickedItemPosition = i;
        myAdapter.toggleExpandedState(i);
        ((ResizeLayout) view).animateToNextState();
    }

    private class MyAdapter extends BaseAdapter {
        private LayoutInflater inflater;
        private Set<Integer> expanded = new HashSet<Integer>();

        public MyAdapter(Context ctx) {
            inflater = LayoutInflater.from(ctx);
        }

        @Override
        public int getCount() {
            return 100;
        }

        @Override
        public Object getItem(int i) {
            return i + 1;
        }

        @Override
        public long getItemId(int i) {
            return i;
        }

        public void toggleExpandedState(int position) {
            if (expanded.contains(position)) {
                expanded.remove(position);
            } else {
                expanded.add(position);
            }
        }

        @Override
        public View getView(int i, View convertView, ViewGroup viewGroup) {
            ResizeLayout layout = (ResizeLayout) convertView;
            TextView title;

            //New instance; no view to recycle.
            if (layout == null) {
                layout = (ResizeLayout) inflater.inflate(R.layout.list_item, viewGroup, false);
                layout.setOnBoundsChangedListener(MyActivity.this);
                layout.setTag(layout.findViewById(R.id.title));
            }

            //Recycling a ResizeLayout; make sure to reset parameters with reuse()
            else layout.reuse();

            //Set the state of the View -- otherwise it will be in whatever state it was before recycling
            layout.setIsExpanded(expanded.contains(i));

            title = (TextView) layout.getTag();
            title.setText("List Item #" + i);

            return layout;
        }
    }
}

list_item.xml

Basic list item layout example. Just has an icon and a title on the top (the icon is set as the collapse_to view) and a message view aligned below.

<?xml version="1.0" encoding="utf-8"?>
<com.example.resize.ResizeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    >

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="10dp">

        <ImageView
            android:id="@+id/collapse_to"
            android:src="@drawable/holoku"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:scaleType="centerInside"
            android:layout_alignParentTop="true"
            android:layout_alignParentLeft="true"
            android:contentDescription="@string/icon_desc"
            tools:ignore="UseCompoundDrawables"
            />

        <TextView
            android:id="@+id/title"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_alignTop="@id/collapse_to"
            android:layout_alignBottom="@id/collapse_to"
            android:layout_toRightOf="@id/collapse_to"
            android:gravity="center_vertical"
            android:paddingLeft="20dp"
            android:textSize="20dp"
            android:textColor="#198EBC"
            />

        <TextView
            android:id="@+id/text"
            android:layout_marginTop="10dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="12dp"
            android:textColor="#444444"
            android:layout_below="@id/collapse_to"
            android:text="@string/message"
            />
    </RelativeLayout>
</com.example.resize.ResizeLayout>

Now I haven't tested it on anything prior to API 17, but running lint checks for NewApi problems says this should work as far back as 2.2 (API 8).

If you'd like to download the sample project and play with it yourself you can download it here.

like image 98
Kevin Coppock Avatar answered Sep 21 '22 11:09

Kevin Coppock