編集:への両方の呼び出しのパラメーターの順序を修正しましたmakeMeasureSpec
。奇妙なことに、それは私が持っていたのとは間違った方法で機能したので、私は冗長なことをしているのだろうかとほとんど思っています。いずれにせよ、それを指摘したかっただけです。以下でダウンロードするプロジェクトには、これらの修正はありません。
さて、これが理解できなかったのは本当に気になりました。そこで、レイアウトと測定システムに慣れることに決めました。これが私が思いついた解決策です。
- 単一の直接の子をホストするカスタム
ViewGroup
拡張(のような)FrameLayout
ScrollView
ListAdapter
各リストアイテムの展開/折りたたみ状態の追跡を処理するカスタム。
OnItemClickListener
折りたたみ状態と展開状態の間でアニメーション化するリクエストを処理する習慣。
他の誰かがそれが役に立つと思う場合に備えて、私はこのコードを共有したいと思います。かなり柔軟なはずですが、バグや改善の余地があることは間違いありません。1つは、プログラムでスクロールする際に問題が発生したためListView
(ビューだけでなく、実際にコンテンツをスクロールする方法はないようです)smoothScrollToPosition(int)
、ビューサイズを変更するたびに使用しました。これにはハードコードされた400msの期間がありますが、これは不要です。したがって、将来的には、期間が0(つまりscrollToPosition(int)
)の独自のバージョンを作成しようとする可能性があります。
一般的な使用法は次のとおりです。
リストアイテムのXMLResizeLayout
には、階層のルートとしてが必要です。そこから、必要なレイアウト構造を構築できます。基本的には、通常のリストアイテムのレイアウトをResizeLayout
タグでラップするだけです。
レイアウトでは、IDを持つ1つのビューが必要ですcollapse_to
。これは、レイアウトが折り返されるビューです(つまり、どのビューが折りたたまれた高さを決定するか)。
リストアダプタを介してリサイクルする場合の重要なこと:
reuse()
リサイクルされたビューを取得するときは常に呼び出します(例convertView
)
setIsExpanded(boolean)
リサイクルされたビューを返す前に、必ず電話してください。それ以外の場合は、リサイクル前の状態を保持します
最終的にこれをgitリポジトリにスローする可能性がありますが、今のところコードは次のとおりです。
ResizeLayout.java
これはコードの大部分です。また、テストに使用した自分Activity
とそれをさらに下に含めます。Adapter
それらは非常に一般的ですが、効果的な使用法を示しています。
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
ListActivity
この例の目的で使用した単純なものです。の子XMLを使用したmain.xml
ジェネリックです。LinearLayout
ListView
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
基本的なリストアイテムのレイアウト例。上部にアイコンとタイトルがあり(アイコンはビューとして設定されていcollapse_to
ます)、メッセージビューが下に配置されています。
<?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>
現在、API 17より前のバージョンではテストしていませんが、NewApiの問題に対してlintチェックを実行すると、2.2(API 8)までさかのぼって機能するはずです。
サンプルプロジェクトをダウンロードして自分で試してみたい場合は、ここからダウンロードできます。