【Android】ListViewのHeader / Footerに関する処理をハックする(考察編)

以前ListViewのHeader/FooterのViewに直接イベントを設定すると言う記事の最後で「って言うかそもそも、addHeaderView / addFooterViewにクリック時のハンドラを渡せるオーバーロードがあればよかったんですがねぇ。」と書いたんですが、もう自分でその機能を実装してしまおうかなと。

と言うわけで今回はListView#addHeaderView / addFooterViewの仕組みをソースレベルで確認しながらどうやったら実装できるか考えていきます。

ListView#addHeaderView / addFooterView

とりあえずソースを覗いてみましょう。Kitkatからはadapterをセットした後でもaddHeaderView / addFooterViewを呼び出しても大丈夫になってました。とは言え、現状Kitkatのシェアは微々たるものだと思いますので、今動いているListViewのソースは大体2.3.1の方でしょう。と言うわけでそちらを引用します。

public void addHeaderView(View v, Object data, boolean isSelectable) {

    if (mAdapter != null) {
        throw new IllegalStateException(
                "Cannot add header view to list -- setAdapter has already been called.");
    }

    FixedViewInfo info = new FixedViewInfo();
    info.view = v;
    info.data = data;
    info.isSelectable = isSelectable;
    mHeaderViewInfos.add(info);
}

public void addHeaderView(View v) {
    addHeaderView(v, null, true);
}

public void addFooterView(View v, Object data, boolean isSelectable) {
    FixedViewInfo info = new FixedViewInfo();
    info.view = v;
    info.data = data;
    info.isSelectable = isSelectable;
    mFooterViewInfos.add(info);

    // in the case of re-adding a footer view, or adding one later on,
    // we need to notify the observer
    if (mDataSetObserver != null) {
        mDataSetObserver.onChanged();
    }
}

public void addFooterView(View v) {
    addFooterView(v, null, true);
}

実装に対して色々と言いたいことはありますが、addHeaderView / addFooterViewを呼び出した時点ではFixedViewInfoと言うクラスに情報を突っ込んでいるだけです。これはListViewの内部クラスなので、そっちも見てみましょう。

// なぜスコープがpublicなのかは謎
public class FixedViewInfo {
    /** The view to add to the list */
    public View view;
    /** The data backing the view. This is returned from {@link ListAdapter#getItem(int)}. */
    public Object data;
    /** <code>true</code> if the fixed view should be selectable in the list */
    public boolean isSelectable;
}

private ArrayList<FixedViewInfo> mHeaderViewInfos = Lists.newArrayList();
private ArrayList<FixedViewInfo> mFooterViewInfos = Lists.newArrayList();

ふーん、って感じですね。

ListView#setAdapterとHeaderViewListAdapter

じゃあmHeaderViewInfos / mFooterViewInfosにセットしたあれこれをAdapterに渡しているのはどこかな?と調べていくと、ListView#setAdapterが引っかかります。これ以外でもListView内部で使ってはいるんですが、実際にAdapterに渡しているのはここだけです。

@Override
public void setAdapter(ListAdapter adapter) {
    if (null != mAdapter) {
        mAdapter.unregisterDataSetObserver(mDataSetObserver);
    }

    resetList();
    mRecycler.clear();

    if (mHeaderViewInfos.size() > 0|| mFooterViewInfos.size() > 0) {
        mAdapter = new HeaderViewListAdapter(mHeaderViewInfos, mFooterViewInfos, adapter);
    } else {
        mAdapter = adapter;
    }
    
    // 省略
}

addHeaderView / addFooterViewを使うとListView#getAdapterで返ってくるadapterの型がWrapperListAdapterになるのはこのせいですね。

実際にはHeaderViewListAdapterと言うadapterをセットしています。リファレンスを読んでも仕方ないので、こちらのソースも手繰っていきましょう。まずはコンストラクタから。

private final ListAdapter mAdapter;

// These two ArrayList are assumed to NOT be null.
// They are indeed created when declared in ListView and then shared.
ArrayList<ListView.FixedViewInfo> mHeaderViewInfos;
ArrayList<ListView.FixedViewInfo> mFooterViewInfos;

// Used as a placeholder in case the provided info views are indeed null.
// Currently only used by some CTS tests, which may be removed.
static final ArrayList<ListView.FixedViewInfo> EMPTY_INFO_LIST =
    new ArrayList<ListView.FixedViewInfo>();

boolean mAreAllFixedViewsSelectable;

private final boolean mIsFilterable;

public HeaderViewListAdapter(ArrayList<ListView.FixedViewInfo> headerViewInfos,
                             ArrayList<ListView.FixedViewInfo> footerViewInfos,
                             ListAdapter adapter) {
    mAdapter = adapter;
    mIsFilterable = adapter instanceof Filterable;

    if (headerViewInfos == null) {
        mHeaderViewInfos = EMPTY_INFO_LIST;
    } else {
        mHeaderViewInfos = headerViewInfos;
    }

    if (footerViewInfos == null) {
        mFooterViewInfos = EMPTY_INFO_LIST;
    } else {
        mFooterViewInfos = footerViewInfos;
    }

    mAreAllFixedViewsSelectable =
            areAllListInfosSelectable(mHeaderViewInfos)
            && areAllListInfosSelectable(mFooterViewInfos);
}

これも大したことしてないですね。そのままadapterのメンバにheaderViewInfosとfooterViewInfosを渡しているだけです。Adapter#getItemあたりは中々参考になります。

public Object getItem(int position) {
    // Header (negative positions will throw an ArrayIndexOutOfBoundsException)
    int numHeaders = getHeadersCount();
    if (position < numHeaders) {
        return mHeaderViewInfos.get(position).data;
    }

    // Adapter
    final int adjPosition = position - numHeaders;
    int adapterCount = 0;
    if (mAdapter != null) {
        adapterCount = mAdapter.getCount();
        if (adjPosition < adapterCount) {
            return mAdapter.getItem(adjPosition);
        }
    }

    // Footer (off-limits positions will throw an ArrayIndexOutOfBoundsException)
    return mFooterViewInfos.get(adjPosition - adapterCount).data;
}

他のメソッドでもpositionを使って判定するような処理はどれも似たようなものです。

  1. positionがヘッダの数より小さかったらヘッダから処理
  2. 「position - ヘッダの数」が実際のadapterの数より小さかったら実際のadapterから処理
  3. 上記の2つ以外であればフッタから処理

後は上記のルールをListViewレベルでラップしてやればOKそうです。

Header or Footerがクリックされた時のListenerを作成する

それじゃあListViewを継承したクラスの実装についても考えていきましょう。クラス名はいいものが思いついてないので適当です。

public class ExListView extends ListView {

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

}

ListView、と言うかAdapterViewを継承したクラスでadapterのItem単位(厳密には違うけど)にセットできるListenerは以下の三つです。

この三つのListenerのイベントが呼ばれた際に、そのItemがHeader or Footerだったら自動で別のListenerに飛ばすようにします。

とりあえず飛ばす先のListenerを作りましょう。

public interface OnFixedViewClickListener {
    void onClick(View v, int position);
}

public interface OnFixedViewLongClickListener {
    void onLongClick(View v, int position);
}

public interface OnFixedViewSelectedListener {
    void onSelected(View v, int position);
}

また、ViewとこれらのListenerを紐付けるクラスも作成しておきます。

private static final class FiexdViewEvent {
    private View mView;
    private OnFixedViewClickListener mOnClickListener;
    private OnFixedViewLongClickListener mOnLongClickListener;
    private OnFixedViewSelectedListener mOnSelectedListener;
    
    public FixedViewEvent(View view) {
        mView = view;
    }
    
    public FixedViewEvent(View view, OnFixedViewClickListener onClickListener) {
        mView = view;
        mOnClickListener = onClickListener;
    }
    
    public FixedViewEvent(View view, OnFixedViewLongClickListener mOnLongClickListener) {
        mView = view;
        mOnLongClickListener = onLongClickListener;
    }
    
    public FixedViewEvent(View view, OnFixedViewSelectedListener mOnSelectedListener) {
        mView = view;
        mOnSelectedListener = onSelectedListener;
    }
    
    public void setOnClickListener(OnFixedViewClickListener onClickListener) {
        mOnClickListener = onClickListener;
    }
    
    public void setOnLongClickListener(OnFixedViewLongClickListener onLongClickListener) {
        mOnLongClickListener = onLongClickListener;
    }
    
    public void setOnSelectedListener(OnFixedViewSelectedListener onSelectedListener) {
        mOnSelectedListener = onSelectedListener;
    }
    
    public void onClick(int position) {
        if(mOnClickListener != null) mOnClickListener.onClick(mView, position);
    }
    
    public void OnLongClick(int position) {
        if(mOnLongClickListener != null) mOnLongClickListener.onLongClick(mView, position);
    }
    
    public void OnSelected(int position) {
        if(mOnSelectedListener != null) mOnSelectedListener.onSelected(mView, position);
    }
}

そしてこれらをHeaderとFooterレベルで所持するListも必要です。addHeaderView / addFooterViewが呼ばれる度にそれを追加していきます。まとめるとこんな感じ。

public class ExListView extends ListView {

    public interface OnFixedViewClickListener {
        void onClick(View v, int position);
    }

    public interface OnFixedViewLongClickListener {
        void onLongClick(View v, int position);
    }

    public interface OnFixedViewSelectedListener {
        void onSelected(View v, int position);
    }
    
    private static final class FiexdViewEvent {
        private View mView;
        private OnFixedViewClickListener mOnClickListener;
        private OnFixedViewLongClickListener mOnLongClickListener;
        private OnFixedViewSelectedListener mOnSelectedListener;
        
        public FixedViewEvent(View view) {
            mView = view;
        }
        
        public FixedViewEvent(View view, OnFixedViewClickListener onClickListener) {
            mView = view;
            mOnClickListener = onClickListener;
        }
        
        public FixedViewEvent(View view, OnFixedViewLongClickListener mOnLongClickListener) {
            mView = view;
            mOnLongClickListener = onLongClickListener;
        }
        
        public FixedViewEvent(View view, OnFixedViewSelectedListener mOnSelectedListener) {
            mView = view;
            mOnSelectedListener = onSelectedListener;
        }
        
        public void setOnClickListener(OnFixedViewClickListener onClickListener) {
            mOnClickListener = onClickListener;
        }
        
        public void setOnLongClickListener(OnFixedViewLongClickListener onLongClickListener) {
            mOnLongClickListener = onLongClickListener;
        }
        
        public void setOnSelectedListener(OnFixedViewSelectedListener onSelectedListener) {
            mOnSelectedListener = onSelectedListener;
        }
        
        public void onClick(int position) {
            if(mOnClickListener != null) mOnClickListener.onClick(mView, position);
        }
        
        public void OnLongClick(int position) {
            if(mOnLongClickListener != null) mOnLongClickListener.onLongClick(mView, position);
        }
        
        public void OnSelected(int position) {
            if(mOnSelectedListener != null) mOnSelectedListener.onSelected(mView, position);
        }
    }
    
    private ArrayList<FiexdViewEvent> mHeaders;
    private ArrayList<FiexdViewEvent> mFooters;

    public ExListView(Context context) {
        super(context);
    }
    
    public ExListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
    
    public ExListView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }
    
    @Override
    public void addHeaderView(View v, Object data, boolean isSelectable) {
        super.addHeaderView(v, data, isSelectable);
        
        if(mHeaders == null) mHeaders = new ArrayList<FixedViewEvent>();
        mHeaders.add(new FiexdViewEvent(v));
    }
    
    @Override
    public void addHeaderView(View v) {
        super.addHeaderView(v);
        
        if(mHeaders == null) mHeaders = new ArrayList<FixedViewEvent>();
        mHeaders.add(new FiexdViewEvent(v));
    }
    
    // 以下Footerも同じような処理
}

Header / FooterにListenerを紐付ける

当然このままじゃListenerは紐付きません。適当にオーバーロードを追加していきます。

public void addHeaderView(View v, OnFixedViewClickListener onCickListener) {
    super.addHeaderView(v);
    
    if(mHeaders == null) mHeaders = new ArrayList<FixedViewEvent>();
    mHeaders.add(new FiexdViewEvent(v, onCickListener));
}

// 他のイベントも適当に…

既に追加されているHeader / FooterにListenerを追加するメソッドを加えてもいいでしょう。

public void setHeaderEvent(int headerPosition, OnFixedViewClickListener onCickListener) {
    mHeaders.get(headerPosition).setOnClickListener(onCickListener);
}

// 他のイベントも(略)

AdapterView関連のListenerのProxyを作成する

下準備はこんなところでしょう。後は先ほど挙げたAdapterView関連のListenerのイベントをフックしなくてはいけないんですが、これは適当なProxyを作ってしまいます。

例えばAdapterView.OnItemClickListenerならこんな感じです。

private static final class OnItemClickListenerProxy implements OnItemClickListener {
    
    private OnItemClickListener mOriginalListener;
    private ArrayList<FiexdViewEvent> mHeaders;
    private ArrayList<FiexdViewEvent> mFooters;
    
    private static final int NONE = -1;
    
    public OnItemClickListenerProxy(ArrayList<FiexdViewEvent> headers
                                    , ArrayList<FiexdViewEvent> footers
                                    , OnItemClickListener originalListener) {
        mHeaders = header;
        mFooters = footers;
        mOriginalListener = originalListener;
        
    }
    
    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        
        // ヘッダチェック
        int headerCount = getFixedEventCount(mHeader);
        
        if(headerCount != NONE && position < headerCount) {
            mHeaders.get(position).onClick(position);
            return;
        }
        
        int footerCount = getFixedEventCount(mHeader);
        
        // この時点でフッタがなければそのまま元のOnItemClickListenerのイベントを呼び出す
        if(footerCount == NONE) {
            mOriginalListener.onItemClick(parent, view, position, id);
            return;
        }
        
        // 元のOnItemClickListenerのイベントを呼ぶのかフッタのイベントを呼ぶのか判定する
        int adapterCount = parent.getCount() - footerCount;
        
        if(position > adapterCount) {
            int footerPosition = position - adapterCount;
            mFooters.get(footerPosition).onClick(footerPosition);
            return;
        }
        
        mOriginalListener.onItemClick(parent, view, position, id);
    }
    
    private static int getFixedEventCount(ArrayList<FiexdViewEvent> list) {
        return list != null ? list.size() : NONE;
    }
}

AdapterView#setOnItemClickListenerが呼ばれたらこのProxyを渡すようにします。

@Override
public void setOnItemClickListener(OnItemClickListener listener) {
    super.setOnItemClickListener(new OnItemClickListenerProxy(mHeaders, mFooters, listener));
}

で、これを全Listenerの分だけ用意します。だるいですね。

まとめ

他にも色々と考慮しなきゃいけないことはいっぱいあるんですが、基本的な考え方はこんな感じです。

ちゃんと動くのかどうかは実践編に続きます。(最近このパターンばっかり…。)