우동우동우's note

Android Sectioned ListView 본문

Java & Android

Android Sectioned ListView

우동우동우 2013. 2. 8. 00:08

아이폰의 섹션이 유지되는 리스트 뷰에 대한 클라이언트의 지속적 요청을 몇번 받았었다. 

그때 마다 구현의 어려움을 얘기 했었고... 고민 끝에(?) 한번 만들어 보자고 마음을 먹었다. 

작업 시간은 이틀 조금 안되는 시간에 만든 것 같다. 

그래서 그런지 구조가 약간.... 좀 마음에 안든다.... 


클래스는 다음의 3가지를 구현하였다. 

  • Section
  • SectionedAdapter
  • SectionedLIstView

이 리스트 뷰를 사용하는 예제를 우선 보자. 


activity_main.xml



    

    




MainActivity.java

package seo.dongu.view.sectioned;

import java.util.ArrayList;

import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

public class MainActivity extends Activity {

	SectionedListView listview;
	
	SectionedAdapter adapter;
	
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		
		listview = (SectionedListView) findViewById(R.id.sectionedListView1);
		
		adapter = new SectionedAdapter() {
			
			@Override
			public View getSectionView(Section section, View convertView,
					ViewGroup parent) {
				if(convertView == null){
					convertView = View.inflate(parent.getContext(), 
							android.R.layout.simple_list_item_1, null);
				}
				
				TextView tv = (TextView) convertView;
				tv.setText(section.getTitle());
				tv.setBackgroundColor(0xFFCCCCCC);
				
				return convertView;
			}
			
			@Override
			public View getItemView(Section section, String item, View convertView,
					ViewGroup parent) {
				if(convertView == null){
					convertView = View.inflate(parent.getContext(), 
							android.R.layout.simple_list_item_1, null);
				}
				
				TextView tv = (TextView) convertView;
				tv.setText(item);
				tv.setBackgroundColor(0x00000000);
				
				return convertView;
			}
		};
		
		for(int i = 0; i < 30; i++){
			Section section = new Section();
			section.setTitle("section " + i);
			ArrayList list = new ArrayList();
			int count = (int) (Math.random() * 5);
			count++;
			for(int j = 0; j < count; j++){
				list.add("item " + j);
			}
			adapter.addSection(section, list);
		}
		
		listview.setAdapter(adapter);
		
	}
}


일단 activity_main.xml에서 보면 SectionedListView를 선언한다. 

후에 MainActivity.java에서는 선언해둔 SectionedListView를 받아온다. 그리고 나서 미리 구현한 SectionedAdapter를 extend 해서 만든뒤 listview의 setAdapter() 함수를 사용해서 적용한다. 여기서 extend 할 함수는 getSectionView와 getItemView이다. 

getSectionView()함수는 섹션으로 나올 뷰에 사용되고, 유지되는 섹션의 뷰를 만들 때도 사용된다. 

그리고 getItemView()함수는 아이템을 나타내는 뷰이다. 

또 SectionedAdapter에 addSection()함수를 이용해서 데이터를 adapter에 삽입할 수 있다. 

이렇게 하면 사용 방법은 끝!


이제 소스에 대한 설명을 해보자. 


Section 클래스는 설명을 하지 않으려 한다... 내용이 워낙 없어서... 

SectionedAdapter를 예전에 Jeff Sharkey라는 사람이 포스팅한 블로그에서 가져와 조금 많이(?) 변형한 형태이다. 그래서 코드에 author란에 내이름을.... Jeff Sharkey가 누군지 궁금하다면 링크를 따라가 보시길... (참조)


package seo.dongu.view.sectioned;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;

import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;

/**
 * 
 * @author Dong-u Seo
 *
 * @param  Type of Item
 */
@SuppressWarnings("hiding")
public abstract class SectionedAdapter extends BaseAdapter {

	LinkedHashMap> sections;
	
	public SectionedAdapter() {
		sections = new LinkedHashMap>();
	}

	/**
	 * Adding Section and Data of Section
	 * @param section
	 * @param list
	 */
	public void addSection(Section section, List list) {
		sections.put(section, list);
	}
	
	/**
	 * This method is abstract. This method must be defined by SubClasses 
	 * when you extend this class. 
	 * 
	 * This method is called when Section View is creadted. 
	 * @param section
	 * @param parent 
	 * @param convertView 
	 * @return
	 */
	public abstract View getSectionView(Section section, View convertView, ViewGroup parent);
	
	/**
	 * This method is abstract. This method must be defined by SubClasses 
	 * when you extend this class. 
* * This method is called when Item View is created. * @param section * @param item * @param parent * @param convertView * @return */ public abstract View getItemView(Section section, Value item, View convertView, ViewGroup parent); /**
* * This method is called when in {@link SectionedListView}. * When this method is called, this method calls * {@link #getSectionView(Section, View, ViewGroup)} * * @param curSection * @return */ View getHeaderView(Section section, ViewGroup parent){ return getSectionView(section, null, parent); } public final Section getCurrentSection(int position){ for(Section section : this.sections.keySet()){ List list = sections.get(section); int size = (list.size() > 0)? list.size()+1 : 0; if (position == 0){ return section; } if (position < size ){ return section; } position -= size; } return null; } @Override public final Object getItem(int position) { for(Section section : this.sections.keySet()){ List list = sections.get(section); int size = (list.size() > 0)? list.size()+1 : 0; if (position == 0){ return section; } if (position < size ){ return list.get( position - 1); } position -= size; } return null; } @Override public long getItemId(int position) { return position; } @Override public final int getCount() { int count = 0; Set
keys = sections.keySet(); for(Section key : keys){ if(sections.get(key).size() > 0){ count += sections.get(key).size() + 1; } } return count; } @Override public final View getView(int position, View convertView, ViewGroup parent) { for(Section section : sections.keySet()){ List list = sections.get(section); int size = (list.size() > 0)? list.size()+1 : 0; if (position == 0){ return getSectionView(section, convertView, parent); } if (position < size){ return getItemView(section, list.get(position - 1), convertView, parent); } position -= size; } return null; } }

영어가 조금 짧아서 주석이 엉망이다. 여기서는 LinkedHashMap<Section, List<Value>> 형태로 구현하였다. 우선 LinkedHashMap 형태로 구현한 이유는 Section의 순서를 기억하기 위해서 이다. 처음에 HashMap으로 구현을 했을 때 섹션의 순서가 뒤죽박죽 되어서 HashMap이 순서가 없는 구조임을 다시 한번 실감했다는.... LinkedHashMap의 경우는 HashMap과는 다르게 순서를 가지고 있다. 심도있게 HashMap과 LinkedHashMap의 구조를 공부를 하지 않아 이유를 정확하게 말하지는 못하겠다. 예전에 공부했던 데이터 구조론을 다시 공부해야겠다는..... 그리고, Value는 제너릭 타입이다. 나머지는 간단한 알고리즘이다. 아마 보면 쉽게 알 것으로 생각된다. 

SectionedListView는 구글의 Contact 앱의 소스보고 참고하여 구현한 것이다. Contact 소스는 https://android.googlesource.com/에서 풀소스 중에 /platform/packages/apps/Contacts에 있는데... Git Repository를 사용하면 받을 수 있다. Eclipse에서 Git plugin이 있으니 활용하여 다운 받아 보시길... (remote.origin.url https://android.googlesource.com/platform/packages/apps/Contacts) 참고로 구글에서 만든 기본 앱의 소스는 받아서 볼 수 있다. 단... 컴파일을 하려면 android 풀 소스가 있어야 하는 걸로 알고있다. 혹시 누가 방법을 알고 있다면 알려주시길.... 


package seo.dongu.view.sectioned;

import java.util.HashMap;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AbsListView;
import android.widget.AbsListView.OnScrollListener;
import android.widget.ListAdapter;
import android.widget.ListView;

/**
 * Sectioned ListView 
 * @author Dong-u Seo
 *
 */
@SuppressWarnings("rawtypes")
public class SectionedListView extends ListView implements OnScrollListener{

	/** indicate whether Header View is shown or not. */ 
	boolean headerVisible = true;

	/** adapter */
	SectionedAdapter adapter;

	/** Current Section */
	Section curSection;

	/** Current First Position */ 
	int curFirstPosition;

	/** header map */
	HashMap headers;

	/** Scroll listener */
	OnScrollListener mOnScrollListener;

	/** Left padding of Header */
	private int mHeaderPaddingLeft;

	/** Top padding of Header */
	private int mHeaderPaddingTop;

	/** bound of header */
	private RectF mBounds = new RectF();

	int mHeaderWidth;

	/**
	 * HeaderView class 
	 * @author Dong-u Seo
	 *
	 */
	class HeaderView {
		View view;
		Section section;
		Rect rect;
		int marginTop;
	}

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

	public SectionedListView(Context context, AttributeSet attrs) {
		super(context, attrs);
		init();
	}

	public SectionedListView(Context context) {
		super(context);
		init();
	}

	/**
	 * get status of Visibility of Header View 
	 * @return if true header is visible, false header View is invisible
	 */
	public boolean isHeaderVisible() {
		return headerVisible;
	}

	/**
	 * set visiblility of Header View
	 * @param headerVisible - if true header is visible, 
	 * false header View is invisible
	 */
	public void setHeaderVisible(boolean headerVisible) {
		this.headerVisible = headerVisible;
	}

	@Override
	public void setAdapter(ListAdapter adapter) 
	{	
		if(adapter instanceof SectionedAdapter){
			this.adapter = (SectionedAdapter) adapter;
			super.setAdapter(adapter);
		}else{
			// throw Exception if adapter is not SectionedAdapter
			throw new IllegalArgumentException(
					"adapter is not SectionedAdapter :: " + adapter);
		}
	}

	/**
	 * Initialize View
	 */
	private void init() {
		headers = new HashMap();
		setOnScrollListener(this);
	}

	@Override
	protected void dispatchDraw(Canvas canvas) {
		super.dispatchDraw(canvas);

		if(isHeaderVisible() && adapter != null && curSection != null){
			// get current HeaderView 
			HeaderView header = headers.get(curSection);

			// if header is null create one
			if(header == null){
				header = createHeaderView(curSection);
				headers.put(curSection, header);

				// init Header Layout
				ensureHeaderLayout(curSection);
			}

			// draw Header
			drawHeader(canvas, header);
		}
	}

	/**
	 * Drawing HeaderView draw headerView with {@link HeaderView}. 
	 * In {@link HeaderView}, the information of header is included.
	 * Like, {@link View}, size of View( {@link Rect} ), and marginTop.
	 *  
	 * @param canvas
	 * @param header
	 */
	private void drawHeader(Canvas canvas, HeaderView header) {
		View view = header.view;
		canvas.translate(mHeaderPaddingLeft, header.marginTop + mHeaderPaddingTop);
		// 2.2 에 맞게 변경
		mBounds.set(0, 0, mHeaderWidth, view.getHeight());
		int alpha = (int)(((view.getHeight()+header.marginTop)/(float)view.getHeight()) * 255);
		canvas.saveLayerAlpha(mBounds, alpha, Canvas.ALL_SAVE_FLAG);
		//		if(header.marginTop < 0){
		//			mBounds.set(0, 0, mHeaderWidth, view.getHeight());
		//			int alpha = (int)(((view.getHeight()+header.marginTop)/(float)view.getHeight()) * 255);
		//			canvas.saveLayerAlpha(mBounds, alpha, Canvas.ALL_SAVE_FLAG);
		//		}
		// 2.2 에 맞게 변경
		
		view.draw(canvas);
	}



	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {
		super.onLayout(changed, l, t, r, b);
		// get layout of this view
		mHeaderPaddingLeft = getPaddingLeft();
		mHeaderPaddingTop = getPaddingTop();
		mHeaderWidth = r - l - mHeaderPaddingLeft - getPaddingRight();
	}

	/**
	 * set Header layout
	 * @param section
	 */
	private void ensureHeaderLayout(Section section) {
		HeaderView header = headers.get(section);
		View view = header.view;
		if (view.isLayoutRequested()) {
			// contact 소스에서 발췌
			int widthSpec = MeasureSpec.makeMeasureSpec(mHeaderWidth, MeasureSpec.EXACTLY);
			int heightSpec;
			ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
			if (layoutParams != null && layoutParams.height > 0) {
				heightSpec = MeasureSpec.makeMeasureSpec(layoutParams.height, MeasureSpec.EXACTLY);
			} else {
				heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
			}

			// -----------------------------------------------------------------------------
			// This code is inserted, because there was a NullPointerError by mLayoutParams
			// in RelativeLayout class.
			if(view.getLayoutParams() == null){
				view.setLayoutParams(new ViewGroup.LayoutParams(
						widthSpec, heightSpec));
			}
			// -----------------------------------------------------------------------------

			view.measure(widthSpec, heightSpec);
			int height = view.getMeasuredHeight();
			if(header.rect == null){
				header.rect = new Rect();	
			}
			header.rect.left = 0;
			header.rect.top = 0;
			header.rect.right = mHeaderWidth;
			header.rect.bottom = height;
			view.layout(0, 0, mHeaderWidth, height);
			// contact 소스에서 발췌

			// margin setting

			int firstVisiblePosition = pointToPosition(
					getPaddingLeft() + 1, 1 );

			updateHeaderMagin(header, firstVisiblePosition);
		}
	}

	/**
	 * Create Header
	 * @param section
	 * @return
	 */
	private HeaderView createHeaderView(Section section) {
		HeaderView tmp = new HeaderView();
		tmp.view = adapter.getHeaderView(section, this);
		tmp.section = section;
		return tmp;
	}

	/**
	 * update margin of HeaderView
	 * @param header
	 * @param firstVisibleItem
	 */
	private void updateHeaderMagin(HeaderView header, int firstVisibleItem) {
		if(header != null){
			int firstVisiblePosition = firstVisibleItem;
			int position = firstVisiblePosition + 1;

			if(position >= 0){
				Object item = adapter.getItem(position);
				if(item instanceof Section){
					int index = position - firstVisiblePosition;
					View child = getChildAt(index);
					if(child == null){
						return;
					}
					if(index > 0){
						header.marginTop = child.getTop() - header.rect.bottom;
						if(header.marginTop > 0){
							header.marginTop = 0;
						}
					}
				}else{
					header.marginTop = 0;
				}
			}

		}
	}

	@Override
	public void onScroll(AbsListView view, int firstVisibleItem,
			int visibleItemCount, int totalItemCount) {
		curFirstPosition = firstVisibleItem;
		if(adapter != null){
			Section tmp = adapter.getCurrentSection(curFirstPosition);
			if(!tmp.equals(curSection)){
				curSection = tmp;
			}
			HeaderView header = headers.get(curSection);
			updateHeaderMagin(header, firstVisibleItem);
		}

		if (mOnScrollListener != null) {
			mOnScrollListener.onScroll(this, firstVisibleItem, 
					visibleItemCount, totalItemCount);
		}
	}

	@Override
	public void onScrollStateChanged(AbsListView view, int scrollState) {

		if (mOnScrollListener != null) {
			mOnScrollListener.onScrollStateChanged(this, scrollState);
		}
	}
}


HeaderView 라는 내부 플래스를 선언하였다. 이제부터 남아 있는 섹션은 Header라고 표현하겠다. HeaderView 클래스에는 Header의 뷰와 뷰가 나타날 위치등의 정보를 저장한다. 

createHeaderView()함수에서 HeaderView를 생성하고 ensureHeaderLayout()에서 HeaderView의 Layout을 잡게 된다. Layout을 잡는 것은 Contact 소스에서 발췌한 소스이다. 

updateHeaderMargin() 함수에서는 Margin을 계사하는 것인데 이전 섹션뷰와 다음 섹션뷰가 만났을 때 위로 밀리는 효과를 나타내기 위해서 Margin을 계한한 것이다. 

drawHeader()함수에서는 Header를 그리는 부분이다.  


코드에 대한 자세한 설명은 생략하겠다. 

소스는 아래에 링크에 있다. 


sectioned.zip



Comments