制作ListAdapter-recycleable可调整大小的视图

kco*_*ock 5 android android-listview layoutparams

我正在创建一个具有扩展和压缩状态的自定义视图 - 在压缩状态下,它只显示一个标签和一个图标,并且在展开状态下,它将在下面显示一条消息.这是截至目前为止它如何工作的截图:

截图

View本身保存用于测量一次冷凝状态和展开状态的大小值,所以它在使用通常的做法视图时(例如,在一个很简单的两个状态之间进行动画,和LinearLayout)一切按预期工作.视图大小的更改是通过调用完成的getLayoutParams().height = newHeight; requestLayout();

但是,在a中使用它时ListView,视图将被回收并保持其先前的高度.因此,如果视图在隐藏时被展开,那么当它被回收用于下一个列表项时,它将显示为展开.它似乎没有收到另一个布局传递,即使我请求布局ListAdapter.我考虑使用具有两种不同视图类型(扩展和压缩)的回收器,但大小将根据消息的大小而变化.是否有一个事件,我可以听到视图重新附加ListView?或者你有另外一个如何处理这个问题的建议?

编辑:这就是我如何确定视图的扩展和压缩高度:

@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;
    }
}
Run Code Online (Sandbox Code Playgroud)

kco*_*ock 7

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. 在您的布局中,您应该有一个带有id的视图collapse_to.这是布局将换行的视图(即,哪个视图确定折叠高度).

  3. 如果您通过列表适配器进行回收,则需要执行以下重要操作:

    • reuse()检索循环视图时总是打电话(例如convertView)
    • setIsExpanded(boolean)在返回循环视图之前总是打电话; 否则它将保留其回收之前的状态

我最终可能把它扔进一个git repo,但是现在这里是代码:

ResizeLayout.java

这是大部分代码.我还会包括我ActivityAdapter我用于进一步测试的内容.它们非常通用,但它们有效地说明了它的用途.

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);
    }
}
Run Code Online (Sandbox Code Playgroud)

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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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>
Run Code Online (Sandbox Code Playgroud)

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.