안드로이드 ListView + SectionIndexer

주의!

PC에서 보는 것을 추천드립니다. 코드 주석으로 설명하기 때문에 모바일에서는 다소 보기가 어렵습니다.


앱을 개발하다 보면 꽤나 귀찮은 기능을 개발해달라고 나오는 경우가 많습니다.

어떻게 보면, 이 ListView + SectionIndexer 도 그 ‘귀찮은 기능’ 에 포함될지도 모르겠습니다. 아래의 조건과 함께라면요.

  • 당연하게도, 있는 항목만 나와야 함
  • 만일 인덱스 되는 글자가 적거나 많을 경우엔 여백으로 조정해야 함
  • 클릭하면 해당 위치 이동
  • 스크롤하면 해당 위치로 이동
  • 한글 -> 영어 순으로 정렬되어야 함
  • 클릭 또는 스크롤 시에 중간에 글자가 잠깐동안 떠야 함
  • 한글은 초성으로 인덱스화

최종적으로는 이러한 모양으로 나오게 됩니다.

1단계, 인덱스 맵 만들기

인덱스 맵이란, 예를 들어

가, 가나다라, 리스트, 마바, 바보, 사자, 스크롤 이란 리스트가 주어졌으면

  • ‘ㄱ’ -> 0
  • ‘ㄹ’ -> 2
  • ‘ㅁ’ -> 3
  • ‘ㅂ’ -> 4

이런 식으로 맵이 나오는 형태입니다. 키에는 해당 인덱스, 뒤에는 해당 인덱스가 처음으로 시작되는 위치 값입니다.

당연히 이를 위해 정렬이 먼저 되어있어야 합니다.

for (int i = 0; i < keywordList.size(); i++) {
    String item = keywordList.get(i); // 키워드 추출
    String index = item.substring(0, 1); // 키워드의 첫 글자 추출

    char c = index.charAt(0); // char 형태 변환
    if (isKorean(c)) { // 한글이면?
        index = String.valueOf(KoreanChar.getCompatChoseong(c)); // 초성 추출
    }

    if (mapIndex.get(index) == null) // 인덱스 맵에 해당 인덱스가 없을 경우
        mapIndex.put(index, i); // 추가
}

isKorean은 아래와 같습니다.

private static boolean isKorean(char ch) {
    return ch >= Integer.parseInt("AC00", 16) && ch <= Integer.parseInt("D7A3", 16);
}

이렇게 해서 최종적으로 SectionIndexer에 넣을 sections 는 완성됩니다.

2단계, SectionIndexer 구현하기

다행이게도 SectionIndexer는 리스트뷰 어댑터에 구현만 하면 됩니다. 구현하게 되면, 아래와 같은 메소드가 나오게 됩니다.

@Override
public Object[] getSections() { // 인덱스 맵에서 key만 추출하여 문자열 배열에 담습니다.
    return sections;
}

@Override
public int getPositionForSection(int section) { // 문자열 배열에서 해당 포지션에 위치한 글자값을 얻어, 인덱스 맵으로 실제 위치를 찾습니다.
    String letter = sections[section];
    return mapIndex.get(letter);
}

@Override
public int getSectionForPosition(int position) { // 사용되지 않는 듯 합니다.
    return 0;
}

3단계, 그리기

리스트뷰 위에 그려야 해서, onDraw 기능을 이용해 구현하면 됩니다.

    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        float scaledWidth = indWidth * getDensity(); // 인덱서 너비 * 해상도
        leftPosition = this.getWidth() - this.getPaddingRight() - scaledWidth; // 리스트뷰의 너비 - 오른쪽 패딩값 - 실제 높이 * 해상도 로 인덱서가 위치할 지점의 left 좌표가 나옵니다.

        positionRect.left = leftPosition; // 인덱서 지점의 left 좌표
        positionRect.right = leftPosition + scaledWidth; // 인덱서 지점의 right 좌표, left 좌표 + 인덱서 너비
        positionRect.top = this.getPaddingTop(); // 위 패딩값 
        positionRect.bottom = this.getHeight() - this.getPaddingBottom(); // 리스트뷰의 높이 - 밑 패딩값

        canvas.drawRoundRect(positionRect, radius, radius, backgroundPaint); // positionRect에 설정한 대로 그리기, 모서리가 둥글어야 해서 radius 값 부여
        indexSize = (this.getHeight() - this.getPaddingTop() - getPaddingBottom()) / sections.length; // 높이 - 위 패딩값 - 아래 패딩값 을 인덱스 문자열 배열의 갯수로 나눈 것. 이것으로 각 인덱스 사이마다 나올 여백값이 나옵니다.

        textPaint.setTextSize(scaledWidth / 2); // 인덱서 너비의 반 정도 크기
        for (int i = 0; i < sections.length; i++) {
            canvas.drawText(sections[i].toUpperCase(), leftPosition + textPaint.getTextSize() / 2, // 각각 인덱스를 그림. 왼쪽은 left 좌표 + 텍스트의 크기 / 2 (중간)
                    getPaddingTop() + indexSize * (i + 1), textPaint); // y는 위 패딩값 + 위에서 게산한 여백값 * (i + 1)
        }

        sectionTextPaint.setTextSize(50 * getScaledDensity()); // 잠깐 뜨는 글자뷰 (여기서는 패스트 뷰라고 하겠습니다) 의 너비 * 해상도
        if (useSection && showLetter & !TextUtils.isEmpty(section)) { // section가 비어있지 않으면
            float mPreviewPadding = 5 * getDensity(); 
            float previewTextWidth = sectionTextPaint.measureText(section.toUpperCase()); // 패스트 뷰의 크기 계산
            float previewSize = 2 * mPreviewPadding + sectionTextPaint.descent() - sectionTextPaint.ascent(); // baseline의 아래 크기 - 위 크기를 뺀 값에 2 * (5 * 해상도) 를 더함

            sectionPositionRect.left = (getWidth() - previewSize) / 2; // 리스트뷰의 너비 - 패스트 뷰의 크기 / 2
            sectionPositionRect.right = (getWidth() - previewSize) / 2 + previewSize; // 리스트뷰의 너비 - 패스트뷰의 크기 / 2 + 패스트뷰의 크기
            sectionPositionRect.top = (getHeight() - previewSize) / 2; // 리스트뷰의 높이 - 패스트뷰의 크기 / 2
            sectionPositionRect.bottom = (getHeight() - previewSize) / 2 + previewSize; // 리스트뷰의 높이 - 패스트뷰의 크기 / 2 + 패스트뷰의 크기

            canvas.drawRoundRect(sectionPositionRect, mPreviewPadding, mPreviewPadding, sectionBackgroundPaint); // sectionPositionRect에 설정한대로 그리기, 5 * 해상도 만큼만 둥글게
            canvas.drawText(section.toUpperCase(),
                    sectionPositionRect.left + (previewSize - previewTextWidth) / 2 - 1, // left 지점 + 패스트뷰의 크기 - 텍스트의 크기 / 2 - 1
                    sectionPositionRect.top + mPreviewPadding - sectionTextPaint.ascent() + 1, sectionTextPaint); // top 지점 + (5 * 해상도) - baseline 위로의 크기 + 1
        }
    }

onDraw에서 처리하니 잠깐동안 뜨는 글자 뷰가 리스트뷰의 Divider에 가려져, dispatchDraw에서 그리도록 호출하면 됩니다.

4단계, 터치 이벤트 – 눌렀을 때

터치 리스너를 받아 처리하면 됩니다.

 case MotionEvent.ACTION_DOWN: { // 눌렀을 때 
                if (x < leftPosition) { // x 좌표 위치가 인덱서의 위치가 아니면 
                    return super.onTouchEvent(event); // 일반 클릭
                } else { // 인덱서를 눌렀음
                    try {
                        float y = event.getY() - this.getPaddingTop() - getPaddingBottom(); // 누른 지점의 y 좌표
                        int currentPosition = (int) Math.floor(y / indexSize); // y 좌표를 여백값으로 반올림, 이렇게 되면 현재 클릭한 위치의 배열 포지션을 알 수 있습니다.
                        section = sections[currentPosition]; // 패스트 뷰를 띄우기 위한 값 저장
                        showLetter = true; // 패스트 뷰를 띄워도 됨
                        this.setSelection(((SectionIndexer) getAdapter()).getPositionForSection(currentPosition)); // 그 위치로 스크롤
                    } catch (Exception e) {
                        Log.v(KoreanIndexerListView.class.getSimpleName(),
                                "Something error happened. but who ever care this exception? " + e.getMessage());
                    }
                }

                break;
}

5단계, 터치 이벤트 – 놓았을 때

놓은 후 사라져야 하니, 핸들러를 둬서 처리하면 됩니다.

 case MotionEvent.ACTION_UP: {
                listHandler.postDelayed(showLetterRunnable, delayMillis);
                break;
 }

 private Runnable showLetterRunnable = new Runnable() {
        @Override
        public void run() {
            showLetter = false;
            this.invalidate();
        }
 };

6단계, 터치 이벤트 – 움직일 때

즉 사용자가 인덱스 바를 누르고 이리저리 스크롤 하는 경우입니다. 4단계와 거의 같습니다.

if (x < leftPosition) {
        return super.onTouchEvent(event);
} else {
        try {
            float y = event.getY();
            int currentPosition = (int) Math.floor(y / indexSize);
            section = sections[currentPosition];
            showLetter = true;
            this.setSelection(((SectionIndexer) getAdapter()).getPositionForSection(currentPosition));
        } catch (Exception e) {
            Log.v(KoreanIndexerListView.class.getSimpleName(),
                  "Something error happened. but who ever care this exception? " + e.getMessage());
            }
}

이렇게 하면 위에 나온 조건들은 대부분 처리가 됩니다.

마지막으로…

본 포스트에 쓰인 코드들은 라이브러리로 공개하였습니다.

https://github.com/WindSekirun/KoreanIndexerListView