2015년 7월 13일 월요일

안드로이드 Custom Component 만들기

안드로이드 개발을 하다보면 Custom Component 혹은 Custom View를 만들일이 간혹 발생합니다. 가능하면 기본 컴포넌트를 사용하는게 좋지만 불가피할 경우에는 만들어야 합니다. 처음에 커스텀뷰를 만들때 Android 공식홈페이지의 글을 읽었지만 이론적으로만 습득하고 실제로는 어떻게 구현해야하는지 몰라 막막했었는데 이 글을 통해 처음 안드로이드 커스텀뷰를 만들어보시는 분들에게 도움이 되었으면 합니다.

꼭 Android 공식홈페이지의 글을 읽어보시기 바랍니다. onDraw, onMeasure등 커스텀 뷰를 만들기 위한 필수적인 지식들이 있습니다.


그러면 Android Library GearSlider를 만드는 과정을 통해 커스텀 컴포넌트를 요구사항 분석부터 구현까지 보여드리도록 하겠습니다.

"인스타그램 ADJUST 화면아래 조정하는거 이쁘던데, 그거 적용해 보는게 어떨까?" 라는 막연한 문장에서 부터 시작되었습니다. 


요구사항분석

저는 작업을 시작하기 전에 노트를 활용합니다. 어떤 클래스가 필요하고 어떤 뷰들의 조합으로 구성이 될 수 있을까?
gearslider를 만들때 제일 처음에 그려본것


단계 1: 컴포넌트를 한, 두 문장으로 정의하기
"손가락을 좌우로 움직여서 막대기 하나마다 value를 1씩 증가시키는 슬라이더" 라고 정리를 해보았습니다.

단계 2: 컴포넌트를 만들기 위한 준비물 리스트 만들기
  1. 터치이벤트를 처리할 View 1
  2. 자처럼 생긴 뒤에서 움직여줄 View 2

처음에는 이렇게 View 2개로 작업하기로 결정했습니다.

단계 3: 컴포넌트의 이동을 상상

"View 1에 scroll event를 주어서 View 2의 x값을 조정하는 방식으로 구현하자"

저는 보통 (1)컴포넌트가 어떠한 형태로 배치가 될지 생각을 해보고 (2)사용자의 입력값에 따라 뷰가 어떻게 이동할지 고민합니다. 작업의 우선순위는 View 1을 만들고 View 2를 생성해야 겠다고 생각했습니다. View 1의 터치이벤트가 가장 먼저 보장되어야 View 2를 움직이는게 의미가 있기 때문입니다.

단계 4: 필요한 기능을 정의 및 구현방법 생각

어떠한 뷰가 필요할지 생각한 후 어떠한 형태로 움직일지 정해졌다면 마지막으로 기능적인 부분을 생각합니다. 

"View 1의 가운데 위치( view1_x+(view1_width/2) )와 View 2의 view2_x 값에 따라 값을 전달하도록 하자."


프로젝트 생성 및 구현

연습용 프로젝트를 하나 생성합니다.

View 1 구현

FrameLayout 을 활용하는 것이 좋다고 판단되어 FrameLayout 을 상속받았습니다.
 public class View1 extends FrameLayout{  
   public View1(Context context, AttributeSet attrs) {  
     super(context, attrs);  
   }  
 }  

GestureDetector.SimpleOnGestureListener를 활용해 scroll 이벤트를 처리하도록 합니다.
 GestureDetector.SimpleOnGestureListener  

setClickable(true)를 통해 클릭이벤트를 처리할 수 있도록 해줍니다. 그리고 onScroll의 e1, e2, distanceX 를 활용해 사용자가 오른쪽, 왼쪽 으로 손가락을 이동하는 모션을 처리하도록 합니다.

 public class View1 extends FrameLayout{  
   private GestureDetectorCompat mDetector;  
   public View1(Context context, AttributeSet attrs) {  
     super(context, attrs);  
     setClickable(true);  
     mDetector = new GestureDetectorCompat(getContext(), new View1GestureListener());  
   }  
   @Override  
   public boolean onTouchEvent(MotionEvent event) {  
     return mDetector.onTouchEvent(event) || super.onTouchEvent(event);  
   }  
   class View1GestureListener extends GestureDetector.SimpleOnGestureListener {  
     @Override  
     public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,  
                 float distanceY) {  
       if (distanceX < 0) {  
         Log.i("TAG", "Left to Right");  
       } else{  
         Log.i("TAG", "Right to Left");  
       }  
       return true;  
     }  
   }  
 }  


자신의 layout 파일에 아래와 같이 컴포넌트를 추가해서 터치이벤트가 잘 먹히는지 확인해봅니다.
   <개인의.패키지명.View1  
     android:background="@android:color/black"  
     android:layout_width="match_parent"  
     android:layout_height="70dp" />  

스크롤 이벤트 탐지가 가능해 졌다면 "손가락을 좌우로 움직여서 막대기 하나마다 value를 1씩 증가시키는 슬라이더"에서 손가락을 좌우로 움직여서라는 작업은 만족하게 되었습니다.

View 2 구현

View 2의 경우는 onDraw()와 onMeasure()를 오버라이드 하여 적절하게 생성해 줍니다. 막대기의 개수는 20개 막대기의 간격은 100으로 지정하였습니다.

 public class View2 extends View {  
   public static int mNumberOfBar = 20;  
   public static int mIntervalOfBar = 100;  
   private float width, height;  
   private Paint paint = new Paint();  
   public View2(Context context) {  
     super(context);  
   }  
   @Override  
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
     width = mIntervalOfBar * mNumberOfBar;  
     height = MeasureSpec.getSize(heightMeasureSpec);  
     setMeasuredDimension((int) width, (int) height);  
   }  
   @Override  
   public void onDraw(Canvas canvas) {  
     paint.setStyle(Paint.Style.STROKE);  
     paint.setStrokeWidth(3);  
     paint.setAntiAlias(false);  
     paint.setColor(Color.BLACK);  
     for (int i = 0; i <= mNumberOfBar; ++i) {  
       float x = i * mIntervalOfBar;  
       canvas.drawLine(x, 0, x, height, paint);  
     }  
   }  
 }  

View 2를 만들었다면 View 1이 View 2를 갖도록 작업을 해야합니다.

View 1에 View 2를 addView()하기

View 1이 xml파일로부터 inflate가 마무리 되는 시점에 View2를 추가해주도록 합니다. onFinishInflate()는 inflate가 마무리 되는 시점에 실행됩니다.
 private View2 mView2;  
 @Override  
 protected void onFinishInflate() {  
   super.onFinishInflate();  
   mView2= new View2(getContext());  
   addView(mView2);  
 }  


View 1의 배경색을 투명한색으로 변경한 후에 실행을 해봅니다.
   <org.nhnnext.josunghwan.sampleproject.View1  
     android:background="@android:color/transparent"  
     android:layout_width="match_parent"  
     android:layout_height="70dp" />  


위와 같이 View 1의 터치이벤트는 입력을 받을 수 있고 View 1의 아래에 View 2가 생성된 모습을 확인하실 수 있습니다.

하지만 현재는 View 1의 터치이벤트와 View 2의 움직임에는 아무런 관련이 없습니다. 이제 View 1의 onScroll()에 코드를 작성하여 View 2의 위치를 변경할 수 있도록 수정하겠습니다.

View 1의 터치이벤트와 View 2의 위치이동을 연결하자


View 1의 onScroll() 코드 부분을 수정하였습니다. (1)View 1의 가운데(view1_width/2)보다 View 2의 x값 - distanceX가 클 경우에는 Too Low를 출력하고 이동하지 않습니다. (2)View 1의 가운데(view1_width/2-1)보다 View 2의 x값 + View 2 - distance의 가로 길이가 작을 경우에는 Too High를 출력하고 이동하지 않습니다. (3) 위의 경우가 아닐때에는 이동된 거리(distanceX)만큼 View 2의 위치를 옮겨주도록 합니다.

 @Override  
     public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,  
                 float distanceY) {  
       if (getWidth() / 2 < (mView2.getX() - distanceX)) {  
         Log.i("TAG", "Too Low");  
       } else if ((mView2.getX() + mView2.getWidth()) - distanceX < (getWidth() / 2) - 1) {  
         Log.i("TAG", "Too High");  
       } else {  
         mView2.setX(mView2.getX() - distanceX);  
       }  
       return true;  
     }  

이제 손가락을 움직일때마다 View 2가 이동되는 모습을 확인하실 수 있습니다.

사용자의 입력에 따라 View가 움직이는 것은 구현이 되었지만, 우리의 어플리케이션은 보여주는 것만으로는 만족할 수 없습니다. View가 갖고있는 값이 무엇인지 알려주어야 합니다.

View 가 갖고있는 값을 갱신하자

int mCurrentValue 를 선언하여 변수값을 저장할 수 있도록합니다. onScroll()의 로직은 원하시는 대로 작성하시면 됩니다. 아래의 코드는 막대기를 넘어갔을때 "Tick"을 출력하게 했고, 가까운 막대기의 값으로 mCurrentValue를 갱신시키는 코드입니다.

 private int mCurrentValue;  
 class View1GestureListener extends GestureDetector.SimpleOnGestureListener {  
   float rulerPosition;  
   @Override  
   public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,  
               float distanceY) {  
     if (getWidth() / 2 < (mView2.getX() - distanceX)) {  
       Log.i("TAG", "Too Low");  
     } else if ((mView2.getX() + mView2.getWidth()) - distanceX < (getWidth() / 2) - 1) {  
       Log.i("TAG", "Too High");  
     } else {  
       int previousValue = (int) (rulerPosition / mView2.mIntervalOfBar);  
       rulerPosition = (mView2.getX() * -1) + (getWidth() / 2);  
       if (previousValue != (int) (rulerPosition / mView2.mIntervalOfBar))  
         Log.i("TAG", "Tick!!");  
       mCurrentValue = Math.round(View2.mNumberOfBar * (rulerPosition) / mView2.getWidth());  
       mView2.setX(mView2.getX() - distanceX);  
     }  
     return true;  
   }  
 }  
 public int getValue(){  
   return mCurrentValue;  
 }  

이렇게 되면 코드는 어느정도 마무리 된것 같습니다. 하지만, 스크롤될때 값이 변경되는것을 다른 컴포넌트에게 알려줄 필요가 있으므로 리스너를 구현하도록 하겠습니다.

리스너를 구현하자

View1에 리스너 인터페이스를 작성합니다. 그리고 리스너를 셋팅할 수 있는 변수와 메서드를 추가합니다.
 private OnValueChangeListener mListener;  
 public interface OnValueChangeListener {  
   void onValueChange(int value);  
 }  
 public void setChangeValueListener(OnValueChangeListener listener) {  
   mListener = listener;  
 }  

그리고 onScroll()에 값이 변경되었을때 리스너의 onValueChange(value)를 실행할 수 있도록 합니다. (코드상 볼드 처리한 곳)

 @Override  
 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,  
             float distanceY) {  
   if (getWidth() / 2 < (mRulerView.getX() - distanceX)) {  
     Log.i(DEBUG_TAG, "Too Low Value");  
   } else if ((mRulerView.getX() + mRulerView.getWidth()) - distanceX < (getWidth() / 2) - DPSIZE) {  
     Log.i(DEBUG_TAG, "Too High Value");  
   } else {  
     int previousValue = (int) (rulerPosition / mIntervalOfBar);  
     rulerPosition = (mRulerView.getX() * -1) + (getWidth() / 2);  
     if (previousValue != (int) (rulerPosition / mIntervalOfBar)) playTickSound();  
     mCurrentValue = Math.round(mNumberOfBar * (rulerPosition) / mRulerView.getWidth());  
     mRulerView.setX(mRulerView.getX() - distanceX);  
     if (mListener != null) {                                 
       mListener.onValueChange(mCurrentValue);                
     }  
   }  
   return true;  
 }  

이제 Custom Component 은 다 만들어 졌습니다! 메인액티비티에 TextView를 추가하고 View1에게 리스너를 등록하여 TextView가 변경되도록 작업 해보겠습니다.

마무리. 예제 만들어보기

activity_main.xml

 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"  
   xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"  
   android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin"  
   android:paddingRight="@dimen/activity_horizontal_margin"  
   android:paddingTop="@dimen/activity_vertical_margin"  
   android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity">  
   <org.nhnnext.josunghwan.sampleproject.View1  
     android:id="@+id/custom_component"  
     android:background="@android:color/transparent"  
     android:layout_width="match_parent"  
     android:layout_height="70dp" />  
   <TextView  
     android:id="@+id/tv1"  
     android:layout_below="@id/custom_component"  
     android:layout_width="wrap_content"  
     android:layout_height="wrap_content" />  
 </RelativeLayout>  

MainActivity.java

 public class MainActivity extends AppCompatActivity {  
   @Override  
   protected void onCreate(Bundle savedInstanceState) {  
     super.onCreate(savedInstanceState);  
     setContentView(R.layout.activity_main);  
     final TextView tv = (TextView) findViewById(R.id.tv1);  
     View1 v1 = (View1) findViewById(R.id.custom_component);  
     v1.setChangeValueListener(new View1.OnValueChangeListener() {  
       @Override  
       public void onValueChange(int value) {  
         tv.setText(String.format("Value : %d", value));  
       }  
     });  
   }  
 }  

값을 7로

값을 20로


이렇게 우리는 요구사항부터 분석하며 Custom Component 를 직접 구현해 보았습니다. 현재 위의 코드 외에도 더 많은 요구사항들이 추가 될 수 있습니다. 막대기를 넘어갈 때 사운드를 출력해라, 가속도에 따라 막대가 움직이는 것을 조정해라, 막대기 근처에 가면 자동으로 막대기로 이동하도록 하여라, 막대기의 길이를 정할 수 있게 해줘라, 애니메이션을 추가해라, xml에서 속성값을 지정할 수 있게 해라 등등 다양한 요구사항이 있고 실제 그것을 만족하는 컴포넌트가 있다고 하더라도 처음에는 가장 핵심적인 기능부터 구현해 나가면 쉽다. 라는것을 전달해 드리고 싶었습니다.  View 1의 경우 GearSlider의 GearSlider.java이고 View 2의 경우 RulerView.java 입니다.  원래는 가운데에 선을 만들고 좌우로 점점 투명해지는 효과를 주는 CenterBar도 같이 작성하려했는데 핵심기능은 아닌거 같아 추가하지 않았습니다.

*본문에 있는 View1, View2, tv, v1등 클래스명과 변수명은 개인적으로는 좋아하진 않지만 괜히 복잡한 이름 때문에 본문을 읽는데 방해가 될까봐 단순하게 작성하였습니다.

GearSlider 코드를 보시면서 새로운 기능을 추가해보시는 것도 도움이 꽤 될 것입니다. 안드로이드 커스텀 컴포넌트를 처음 만들어 "답답했을때 이러한 자료가 있으면 좋겠다"라고 생각했는데 생각했던 것만큼 잘 전달될지는 모르겠습니다. 

댓글 2개:

  1. 좋은 글 감사합니다. 혹시 소스코드를 받을 수 없을런지요?

    답글삭제
    답글
    1. https://github.com/sunghwanJo/GearSlider 지금도 잘 작동할진 모르겠지만 이 주소에 있습니다

      삭제