ListView를 이용하여 목록을 구현하는 방법에는 일정한 패턴이 있다. 여러 목록 페이지를 구현하다 보면 비슷하거나 거의 동일한 코드를 복사해서 붙여넣기를 반복하게 된다. 생산성을 높이고, 중복코드를 제거하고 모듈화하여 재사용성을 높이는 방법을 찾아보자.
목록 페이지의 일반적인 플로우
1. 처음에 페이지에 진입하게 되면 전체 로딩바를 표시하고 첫 데이타 불러오기를 시작한다.
2. 여기서 실패하면 로딩 실패 뷰를 표시한다.
3. 로딩 실패 뷰에서는 재시도 버튼이 있는데 재시도 버튼을 누르면 1번을 다시 반복한다.
4. 1번의 결과 로딩이 성공하여, 응답 데이타가 없다면 empty View (데이타가 없습니다.)를 보여준다.
5. 1번의 결과 로딩이 성공하여, 응답 데이타가 있으면 목록에 표시한다.
6. 목록의 마지막 하단부가 보여지면, 자동으로 더 보기 로딩바를 보여주고, "더 보기 데이타"를 불러오기 시작한다.
7. 6번의 결과가 성공하면 추가적인 데이타를 목록에 보여준다. 다시 하반부에 도달하면 6번을 실행한다.
8. 6번의 결과가 실패하면 더 보기 실패 뷰를 보여준다.
9. 8번 더보기 실패 뷰에서 재시도를 누르면 6번의 과정을 다시 시도한다.
10. pull to refresh를 하거나, 상단의 새로고침 버튼을 누르면 1번을 다시 실행한다.
이런 패턴이 여러 페이지에서 반복되기 때문에 이를 모아서 공통된 모듈로 분리해서 모듈화를 시킬 수 있다.
일단 모듈화를 했을 때 어떻게 간단해지는지 모듈을 사용하는 방법을 설명한다.
ListView 표시할 데이타 모델을 정의한다.
number라는 정수 데이타만 가지는 간단한 테스트 모델이다.
package com.mdiwebma.learninghabit;
public class TestData {
public TestData(int n) {
number = n;
}
public int number;
}
ListView에서 한 Item을 처리할 viewHolder를 구현한다.
inflateView를 뷰를 생성하고, bindeView로 뷰를 업데이트한다.
public static class TestViewHolder implements ViewHolder<TestData> {
TextView textView;
ImageView imageView;
TestData testData;
@Override
public View inflateView(Context context) {
LayoutInflater a;
View baseView = ((LayoutInflater)context.getSystemService(Activity.LAYOUT_INFLATER_SERVICE)).inflate(R.layout.test_view, null);
textView = (TextView)baseView.findViewById(R.id.text_view);
imageView = (ImageView)baseView.findViewById(R.id.image);
baseView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
ToastUtils.show(String.valueOf(testData.number));
}
});
return baseView;
}
@Override
public void bindView(TestData item) {
testData = item;
textView.setText(String.valueOf(testData.number));
ImageLoader.getInstance().displayImage(getUrl(testData.number), imageView, LearningHabitApp.getImageCacheOptions());
}
}
ListView를 초기화한다.
loading의 요청을 처리할 OnLoadingListener를 구현하고, adapter를 초기화한다. requestLoading으로 첫번째 데이타를 로딩을 요청한다.
simpleListController.setOnLoadingListener(new SimpleListController.OnLoadingListener() {
@Override
public void onLoading(boolean retry) {
new MyTask(0, retry).setThreadName("requestLoading").execute().showProgressDialog(SplashActivity.this, 1);
}
@Override
public void onMoreLoading(boolean retry) {
new MyTask(lastData, retry).setThreadName("readMore").execute();
}
});
adapter = new SimpleListAdapter<TestData>(this, simpleListController, TestViewHolder.class);
listView.setAdapter(adapter);
simpleListController.requestLoading(false);
MyTask의 구현
myTask의 구현은 간단하다. simpleListController의 메쏘드를 적절히 호출해 주면 상태에 따른 view는 자동으로 변경된다.
class MyTask extends ExAsyncTask<Object, Void, Void> {
int startNumber;
boolean retry;
List<TestData> list = new ArrayList<TestData>();
MyTask(int startNumber, boolean retry) {
this.startNumber = startNumber;
this.retry = retry;
}
@Override
protected Void doInBackground(Object... p) {
for (int i = startNumber + 1; i <= startNumber + 20; i++) {
list.add(new TestData(i));
lastData = i;
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return null;
}
@Override
protected void onPostExecute(Void r) {
if (startNumber == 0) {
if (retry) {
adapter.addAll(list);
simpleListController.setLoadingSucceeded(list.size() == 0, true);
} else {
simpleListController.setLoadingFailed(new RuntimeException("loading failed"));
}
} else {
if (retry) {
adapter.addAll(list);
simpleListController.setMoreLoadingSucceeded(true);
} else {
simpleListController.setMoreLoadingFailed(new RuntimeException("read more failed"));
}
}
adapter.notifyDataSetChanged();
}
}
공통 모듈의 구현
ViewHolder
Item을 표시하는 ViewHolder는 이 클래스를 implements 해야 한다.
package com.mdiwebma.base.simplelist;
import android.content.Context;
import android.view.View;
public interface ViewHolder<T> {
View inflateView(Context context);
void bindView(T item);
}
MoreLoadingViewHolder
목록의 하단의 더보기 구현, adapter에서 변경가능하고, 따로 지정하지 않으면 MoreLoadingViewHolder가 기본으로 동작한다.
package com.mdiwebma.base.simplelist;
import android.app.Activity;
import android.content.Context;
import android.support.annotation.NonNull;
import android.view.LayoutInflater;
import android.view.View;
import com.mdiwebma.base.R;
import com.mdiwebma.base.utils.ViewUtils;
public class MoreLoadingViewHolder implements ViewHolder {
@NonNull
final SimpleListController simpleListController;
@NonNull
View loadingView;
@NonNull
View retryView;
MoreLoadingViewHolder(SimpleListController simpleListController1) {
this.simpleListController = simpleListController1;
}
@Override
public View inflateView(Context context) {
LayoutInflater inflater = (LayoutInflater)context.getSystemService(Activity.LAYOUT_INFLATER_SERVICE);
View baseView = inflater.inflate(R.layout.read_more_view, null);
loadingView = baseView.findViewById(R.id.loading_layout);
retryView = baseView.findViewById(R.id.retry_layout);
baseView.findViewById(R.id.retry).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
ViewUtils.setVisible(loadingView);
ViewUtils.setGone(retryView);
simpleListController.requestMoreLoading(true);
}
});
return baseView;
}
@Override
public void bindView(Object item) {
if (simpleListController.getMoreLoadingState() == SimpleListController.LoadingState.NORMAL) {
ViewUtils.setVisible(loadingView);
ViewUtils.setGone(retryView);
simpleListController.requestMoreLoading(false);
} else if (simpleListController.getMoreLoadingState() == SimpleListController.LoadingState.FAILED) {
ViewUtils.setGone(loadingView);
ViewUtils.setVisible(retryView);
}
}
}
SimpleListActivity
별거 없다. 뷰를 초기화하고 각 상태별로 보여줘야 할 뷰의 visible을 변경해 주면 된다.
package com.mdiwebma.base.simplelist;
import android.os.Bundle;
import android.view.View;
import com.mdiwebma.base.BaseActivity;
import com.mdiwebma.base.R;
import com.mdiwebma.base.utils.ViewUtils;
public class SimpleListActivity extends BaseActivity {
protected View listView;
protected View loadingView;
protected View emptyView;
protected View errorView;
protected View retryButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.simple_list);
listView = findViewById(R.id.list_view);
loadingView = findViewById(R.id.loading_layout);
emptyView = findViewById(R.id.empty_layout);
errorView = findViewById(R.id.error_layout);
retryButton = findViewById(R.id.retry_button);
}
protected final SimpleListController simpleListController = new SimpleListController() {
@Override
public void changeLoadingProgressView() {
ViewUtils.setGone(listView);
ViewUtils.setVisible(loadingView);
ViewUtils.setGone(emptyView);
ViewUtils.setGone(errorView);
ViewUtils.setGone(retryButton);
}
@Override
protected void changeLoadingSucceededView(boolean isEmpty) {
ViewUtils.setVisibility(listView, isEmpty == false);
ViewUtils.setGone(loadingView);
ViewUtils.setVisibility(emptyView, isEmpty);
ViewUtils.setGone(errorView);
ViewUtils.setGone(retryButton);
}
@Override
protected void changeLoadingFailedView() {
ViewUtils.setGone(listView);
ViewUtils.setGone(loadingView);
ViewUtils.setGone(emptyView);
ViewUtils.setVisible(errorView);
if (retryButton != null) {
retryButton.setVisibility(View.VISIBLE);
retryButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
simpleListController.requestLoading(true);
}
});
}
}
};
}
SimpleListAdapter
adapter는 generic으로 중복구현을 없앤다.
package com.mdiwebma.base.simplelist;
import android.content.Context;
import android.support.annotation.NonNull;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import com.mdiwebma.base.logging.Dlog;
public class SimpleListAdapter<T> extends ArrayAdapter<T> {
private static final int VIEWTYPE_ITEM = 0;
private static final int VIEWTYPE_MORE = 1;
private static final int VIEWTYPE_COUNT = 2;
@NonNull
final SimpleListController simpleListController;
@NonNull
final Class<? extends ViewHolder<T>> viewHolderClass;
ViewHolder moreLoadingViewHolder;
public SimpleListAdapter(Context context, SimpleListController simpleListController,
Class<? extends ViewHolder<T>> viewHolderClass) {
super(context, 0);
this.viewHolderClass = viewHolderClass;
this.simpleListController = simpleListController;
}
public void setMoreLoadingViewHolder(ViewHolder moreLoadingViewHolder) {
this.moreLoadingViewHolder = moreLoadingViewHolder;
}
@Override
public int getCount() {
return super.getCount() + (simpleListController.hasMore() ? 1 : 0);
}
@Override
public int getViewTypeCount() {
return VIEWTYPE_COUNT;
}
@Override
public int getItemViewType(int position) {
if (position < super.getCount()) {
return VIEWTYPE_ITEM;
}
else {
return VIEWTYPE_MORE;
}
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
final int viewType = getItemViewType(position);
if (viewType == VIEWTYPE_ITEM) {
final T item = getItem(position);
if (convertView == null) {
ViewHolder<T> viewHolder = null;
try {
viewHolder = viewHolderClass.newInstance();
convertView = viewHolder.inflateView(getContext());
} catch (InstantiationException | IllegalAccessException ex) {
Dlog.assertException(ex);
}
convertView.setTag(viewHolder);
}
ViewHolder<T> itemViewHolder = (ViewHolder<T>)convertView.getTag();
itemViewHolder.bindView(item);
} else { // VIEWTYPE_MORE
if (moreLoadingViewHolder == null) {
moreLoadingViewHolder = new MoreLoadingViewHolder(simpleListController);
}
if (convertView == null) {
convertView = moreLoadingViewHolder.inflateView(getContext());
convertView.setTag(moreLoadingViewHolder);
}
moreLoadingViewHolder.bindView(null);
}
return convertView;
}
}
SimpleListController
package com.mdiwebma.base.simplelist;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import com.mdiwebma.base.logging.Dlog;
public abstract class SimpleListController {
public static interface OnLoadingListener {
void onLoading(boolean retry);
void onMoreLoading(boolean retry);
}
public enum LoadingState {
NORMAL, //initial state
LOADING,
FAILED,
DONE
}
private LoadingState loadingState = LoadingState.NORMAL;
private LoadingState moreLoadingState = LoadingState.NORMAL;
private boolean hasMore = false;
@Nullable
private OnLoadingListener onLoadingListener;
public void setOnLoadingListener(@Nullable OnLoadingListener onLoadingListener) {
this.onLoadingListener = onLoadingListener;
}
//-----------------------------------------
// full loading
//-----------------------------------------
public void requestLoading(boolean retry) {
this.loadingState = LoadingState.LOADING;
changeLoadingProgressView();
if (onLoadingListener != null) {
onLoadingListener.onLoading(retry);
}
}
public void requestMoreLoading(boolean retry) {
Dlog.assertTrue(hasMore);
moreLoadingState = LoadingState.LOADING;
if (onLoadingListener != null) {
onLoadingListener.onMoreLoading(retry);
}
}
public LoadingState getLoadingState() {
return loadingState;
}
public void setLoadingSucceeded(boolean isEmpty, boolean hasMore) {
loadingState = LoadingState.DONE;
setMoreLoadingSucceeded(hasMore);
changeLoadingSucceededView(isEmpty);
}
public void setLoadingFailed(@NonNull Exception ex) {
loadingState = LoadingState.FAILED;
changeLoadingFailedView();
}
abstract void changeLoadingProgressView();
abstract void changeLoadingSucceededView(boolean isEmpty);
abstract void changeLoadingFailedView();
//-----------------------------------------
// more loading (bottom of list view)
//-----------------------------------------
public boolean hasMore() {
return this.hasMore;
}
public LoadingState getMoreLoadingState() {
return moreLoadingState;
}
public void setMoreLoadingSucceeded(boolean hasMore) {
this.hasMore = hasMore;
if (this.hasMore) {
moreLoadingState = LoadingState.NORMAL;
} else {
moreLoadingState = LoadingState.DONE;
}
}
public void setMoreLoadingFailed(@NonNull Exception ex) {
this.moreLoadingState = LoadingState.FAILED;
}
}