본문 바로가기

Android

[Android] 라이브러리 급의 커스텀뷰 만들기 프로젝트(RulerPicker)

일하다가 자 형태의 눈금 스크롤이 필요했다.

라이브러리를 쓸까 하다가

좀 찾아보니 원하는대로 반영되는걸 찾다가는

스트레스받을게 뻔해보여서 까짓거 걍 만들기로 했다.

근데 만들다보니 꽤 잘 만들어지는게 아닌가?

그리 복잡하지도 않고, 라이브러리급으로 만들어서 배포해봐야겠다 싶었다.

 

작업기간 : 약 2일

조건 :

- 가로스크롤만 반영한다.

- 1 마다 눈금 하나를 그린다.

- 5 마다 긴 눈금 하나를 그린다.

- 최소/최대값을 정할 수 있어야 한다.

- 콜백으로 변한 값을 넘길 수 있어야 한다.

 

결과물 이미지는 아래와 같다.

 

작업순서

 

1. 커스텀뷰 특성 정리

뭔가 만들려면 이해해야 한다.

해당 'RulerPicker'는

 

- 어느 간격 당 눈금을 그릴지 정의해야 한다.

- 간격 당 어느정도 값인지 정의해야 한다.

(ex. 30dp 당 1 )

- 화살표는 가운데에 고정한다.

- Fling 같은 사용자동작은 배제한다.

- Touch 만 이용해서 손가락만 따라 가로 스크롤 되도록 한다.

- 눈금 당 숫자는 정수로 생각한다.

- 눈금이 이동하여 값이 변할 때마다 콜백으로 등록된 리스너를 동작시킨다.

- 좌표가 바뀔 때마다 매번 다시 그려줘야 한다.

- 눈금의 길이는 큰 단위, 작은 단위, 작은단위인데 선택됐을 때 로 나뉜다.

 

이 정도만 정의하고 작업을 시작했다.

 

2. 작동방식 정리

- 해당 뷰를 xml 에서 크기를 잡아 배치하면 기본값과 정의한 속성으로 미리보기를 보여준다.

- 정의한 속성에서 값을 얻어오고 뷰가 생성될 때 뷰 내부 변수에 전달한다.

- 실제 크기는 onMeasure 때 정해진 크기를 getMeasuredWidth 와 getMeasuredHeight 으로 얻는다.

- 정해진 크기를 기준으로 간격을 동적으로 잡아준다.(나는 화면안에 20칸 만큼으로 잡았다)

- 기준이 되는 좌표와 값은 최소값 부터 화면에 중앙부터 시작하는 것이다.

- 최소값일 때 더 아래로 스크롤이 안 되고, 최대값일 때 더 위로 스크롤이 안 되게 막는다.

- 터치하여 좌표가 변해서 새로 계산할 때는 화면 안의 좌표에만 draw를 수행하고 그렇지 않으면 생략한다.

- 외부에서도(EditText 등) 값을 뷰에 입력하여 좌표를 계산할 수 있어야 한다.

 

여기까지만 정의하고 실제 코딩을 시작했다.

 

3. 터치 좌표 받아서 계산

View 를 확장한 RulerPicker 에 OnTouchListener 도 구현하고

onTouch 메서드를 오버라이드했다.

 

1) 터치 시 동작

@Override
    public boolean onTouch(View v, MotionEvent event) {

        //Log.e(getClass().getSimpleName(), "ON TOUCH, " + event.getAction());

        switch (event.getAction()) {

            case MotionEvent.ACTION_DOWN:

                accX += dX;
                accY += dY;

                startX = event.getX();
                startY = event.getY();

                break;

            case MotionEvent.ACTION_MOVE:

                setCoordinate(event.getX() - startX);
                
                break;

            default:
                return false;

        }

        return true;
    }

 

2) setCoordinate 메서드

private void setCoordinate(float dx) {

        inputMode = false;

        float totalWidth = ((max - min) / lineInterval) * gap; //전체 값을 한칸단위로 나눠서 총 몇칸인지를 구하고, 한칸간격 * n 하여 총 가상사이즈를 구함
        float tempValue =  -1f * ((accX + dX) / totalWidth) * (max - min); //시작점에서부터 얼마나 움직였는가를 가상전체사이즈로 쪼갠 값을 최대 - 최소 에 곱하면 값 기준으로 얼마나 움직였는지가 나옴

        if ((tempValue > max) && (dx < dX)) {
            this.value = max;
            postInvalidate();
            return;

        } else if ((tempValue < min) && (dx > dX)) {
            this.value = min;
            postInvalidate();
            return;
        }

        dX = dx;
        this.value = tempValue;
        postInvalidate();


    }

 

사실은 터치동작 자체를 처음 컨트롤해보는 거라서 좌표계산이 많이 헷갈렸다.

계산방법을 말로 풀면

 

터치할 동안엔

누적X 좌표 + dX(지금 터치되고 있는 X 좌표 - 이번에 처음 터치한 X 좌표) 를 최소값 눈금의 좌표라고 본다.

 

다음에 터치할 때는

누적 X 좌표에 dX 를 누적해준다.

 

다음에 터치할 때가 아니라 UP 동작 때 해도 될 것인데 하다보니 이렇게 마무리됐다.

 

최대값과 최소값의 구별은

현재 값이 최소값보다 작거나 현재 값이 최대값보다 크면,

저번 onTouch 때의 움직이는 방향과 이번 onTouch 때의 방향을 비교한다.

더 작아지거나 더 커지는 방향(눈금이 없는 방향)으로 더 이동하려 한다면

값을 최소 혹은 최대로 유지하면서 좌표계산을 제한한다.

 

 

4. 계산된 좌표를 기준으로 보이는 영역만 그리기

해당 while 문이 onDraw 의 거의 모든 코드다.

나머지는 화살표그림 가운데에 그리고 리스너작동시키는 코드다.

 

tempX 와 tempValue 를 최소값과 최소값에 해당하는 눈금이라고 보고

하나씩 그리되, 화면밖에 있는건 안 그리는 구조로 간단하다.

while (true) {
            
            if (tempX < 0) {

                //미리 계산해서 이 반복문도 없애기
                tempX += gap;
                tempValue += lineInterval;
                continue;
            }
            if (tempX > width) {

                break;
            }

            if (tempValue > max) {

                break;
            }

            if ((tempValue % bigInterval) == 0f) {

                //긴 선
                setLabelPaint();
                String text = Integer.toString((int)tempValue);
                paint.getTextBounds(text, 0, text.length(), rect);
                float textHeight = rect.height();
                canvas.drawText(text, tempX, height - textMarginVertical, paint);

                if ((int)v == (int)tempValue) {
                    //타겟

                    float shortLineHeight = (height - textMarginVertical * 2f - textHeight) * 0.5f;
                    setBoldLinePaint();
                    canvas.drawLine(tempX, cursorHeight + marginVertical, tempX, height - textHeight - marginVertical, paint);
                } else {
                    float shortLineHeight = (height - textMarginVertical * 2f - textHeight) * 0.5f;
                    setNormalLongLinePaint();
                    canvas.drawLine(tempX, cursorHeight + marginVertical, tempX, height - textHeight - marginVertical, paint);
                }

                tempX += gap;
                tempValue += lineInterval;
                continue;
            } else {
                //일반선
                String text = Integer.toString((int)tempValue);
                paint.getTextBounds(text, 0, text.length(), rect);
                float textHeight = rect.height();

                if ((int)v == (int)tempValue) {
                    //타겟

                    //float shortLineHeight = (height - textMarginVertical * 2f - textHeight) * 0.5f;
                    setBoldLinePaint();
                    canvas.drawLine(tempX, cursorHeight + marginVertical, tempX, (height - textHeight - marginVertical) * 0.8f, paint);
                } else {
                    //float shortLineHeight = (height - textMarginVertical * 2f - textHeight) * 0.5f;
                    setNormalLinePaint();
                    canvas.drawLine(tempX, cursorHeight + marginVertical, tempX, (height - textHeight - marginVertical) * 0.6f, paint);
                }


                tempX += gap;
                tempValue += lineInterval;
                continue;
            }



        }

 

 

5. 콜백 등록 구현하기

인터페이스 하나 만들어서 등록했다.

 

OnPickListener.java

public interface OnPickListener {
    void onPick(int value);
}

 

RulerPicker 안의 리스너 세터 함수

private OnPickListener pickListener = null;

public void setOnPickListener(OnPickListener listener) {
        this.pickListener = listener;
    }

 

6. 속성선언하기

 

1) 새로운 속성 선언

attrs.xml

<declare-styleable name="RulerPicker">

        <attr name="minValue" format="integer"/>
        <attr name="maxValue" format="integer"/>
        <attr name="initValue" format="integer"/>

    </declare-styleable>

 

2) 새로운 속성 사용

일반적인 다른 속성과 같은 사용방법

app:minValue="0"
app:maxValue="200"
app:initValue="100"

 

3) 코드로 값 가져오기

 

RulerPicker 생성자 부분

Resources.Theme theme = context.getTheme();
TypedArray a = theme.obtainStyledAttributes(attrs, R.styleable.RulerPicker, defStyleAttr, 0);

min = a.getInt(R.styleable.SpiroKitRulerPicker_minValue, 0);
max = a.getInt(R.styleable.SpiroKitRulerPicker_maxValue, 50);
value = a.getInt(R.styleable.SpiroKitRulerPicker_initValue, 25);

 

값을 가져와서 변수에 넘겼으니

아까처럼 좌표계산해서 그려주면 속성이 반영되는 구조다.

(이대로 미리보기도 작동함)

 

7. 주석달기

업무차원에서의 일은 6번에서 마무리됐고,

내 자산을 쌓기 위해 라이브러리에 올리려고 하니

주석이 있어야 하지 않겠는가...?

최대한 간략하고 핵심적인 내용으로 작성하는 중이다.

 

 

 

8. 라이브러리로 등록하기

 

실제로 올리고 작성 예정

 

9. 내가 만든 라이브러리 사용해보기

 

사용 후 GIF 파일과 함께 작성예정

(GIF 는 역시 피날레죠)