개발을 하다보면 외부 라이브러리를 쓰기 보다는 직접 필요한 라이브러리를 만들어서 쓰고 싶은 낭만(?) 같은게 생긴다. 특히, 필자는 나만의 CustomView 를 만들고 싶다는 생각이 Android 를 처음 접한 순간부터 존재했다. 하지만, 이것저것 공부해야한다는 핑계로 미루다 지금이라도 결심을 하고 진행해보려한다.
살짝 나만의 스터디 프로젝트 같은 느낌으로 진행할 예정이다. 먼저, CustomView 에 대한 이론(Overview)을 살펴본 후, RadarChart 를 출력하는 RadarChartView 를 만든다. 그리고, 필자의 최종 Goal 은 "MusicXML 파일을 악보로 출력하는 악보뷰를 만드는 것"이다. 불가능할지도 모르고, 거대한 삽질을 하게될지도 모른다. 하지만, 삽질하는 과정도 재밌기에 조금 큰 목표를 잡아보았다. 우선, CustomView 가 무엇인지에 대해 Android 공식 문서를 기반으로 공부하고 정리한다.
1. Custom View
Android 앱의 UI 를 구현할 때, Button, TextView, ImageView 와 같은 위젯을 비롯해서 ConstraintLayout, LinearLayout, RelativeLayout 등 레이아웃 요소들을 사용한다. 이런 위젯과 레이아웃들 모두 "View", "ViewGroup" 을 상속받은 것들이다. 즉, Java와 Kotlin 에서 "Object" 와 같은 위치에 있는 것이 "View", "ViewGroup" 이라 보면 된다.
기본적으로 Android 에서 제공해주는 위젯과 레이아웃 중에서 필요한 것이 없다면, View 와 ViewGroup 을 상속받아 직접 나만의 위젯을 만들 수 있다. 또는, 특정 위젯이나 레이아웃에서 조금만 수정하면 좋을 것 같다고 생각된다면, 해당 위젯이나 레이아웃을 상속받아서 원하는 모습으로 수정할 수 있다.
View 를 상속받아 본인만의 View 클래스를 만들게 되면, 화면 상에서 나타나는 외형이나 기능 동작에 정교한 제어를 할 수 있다. 예를 들면 다음과 같은 일들을 할 수 있다.
- 완전히 커스텀하여 렌더링되는 View 타입을 만들 수 있다.
- View 요소들을 하나로 결합하여 새로운 요소로 만들 수 있다.
- 기존 위젯을 오버라이드할 수 있다.
- View 에 대한 이벤트 콜백을 자세히 보고, 수집할 수 있다.
2. The Basic Approach
나만의 CustomView 를 만드는 방법을 간략하게 정리하면 다음과 같다.
- 만들고자 하는 CustomView 의 클래스를 View (또는 하위) 클래스로부터 상속 받는다.
- 부모 클래스로부터 "on" 으로 시작하는 메소드들을 오버라이드한다. 예를 들어, "onDraw()", "onMeasure()", "onKeyDown()" 등이 있다.
- 상속을 받고 구현을 마치면, View 를 놓을 수 있는 위치에 자체적으로 만든 CustomView 를 사용할 수 있다.
3. Fully Customized Components
화면에서 상상한 모습 그대로 보여주기 위해서는 View 로부터 완전히 커스텀을 해줘야한다. 뷰 컴포넌트를 완전히 커스터마이징 하는 방법은 다음과 같다.
- View 로부터 상속 받는 클래스를 생성한다.
- XML 상에서 속성과 파라미터를 받을 수 있도록 생성자를 만든다. 생성자를 통해 받는 속성값과 파라미터는 그 안에서 사용할 수 있다.
- 이벤트 리스너, 프로퍼티 Getter 와 Setter, 필요 동작들을 추가한다.
- 화면에 무언가를 그리고 싶다면, onDraw() 와 onMeasure() 함수를 오버라이드한다. 오버라이드 하지 않으면, onDraw() 는 아무런 동작을 하지 않고, onMeasure() 은 크기 값을 100x100 으로 설정한다.
- 필요에 따라 다른 "on~" 으로 시작하는 메소드들을 오버라이드한다.
3-1) onDraw() 와 onMeasure()
class MyOwnView(context: Context, attrs: AttributeSet): View(context, attrs) {
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
}
}
onDraw() 메소드는 "Cavas" 객체를 인자로 전달한다. 전달받은 Cavas 객체를 통해 원하는 모습의 2D 그래픽을 그려낼 수 있다. 단, 3D 그래픽은 그려낼 수 없다. 만약, 3D 그래픽을 그리고 싶다면, View 가 아니라 SurfaceView 를 상속 받아서 구현해야한다.
class MyOwnView(context: Context, attrs: AttributeSet): View(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
onMeasure() 메소드는 onDraw() 와 달리 조금 더 복잡하다. onMeasure() 은 컴포넌트와 그의 컨테이너 간에 렌더링 연동에 사용되는 중요한 부분이다. onMeasure() 은 효율적으로 오버라이드 구현되어야 하며, 정확한 수치 값들을 리포트 해야한다. 만약, 오버라이드한 onMeasure() 안에 코드가 실행되지 않는다면, 그것은 크기를 측정하는 과정에서 Exception 이 발생했다는 것을 뜻한다.
상위 레벨 관점에서, 구현된 onMeasure() 은 다음과 같이 보인다.
- 오버라이드 된 onMeasure() 메소드는 측정된 width, height 값을 인자로 가지고 호출된다. 각 값은 Int 형이며, widthMeasureSpec, heightMeasureSpec 이라는 이름으로 전달된다. 해당 값들은 부모 레이아웃에서 전달해준 크기 제한 값이다. 이에 대한 자세한 정보는 View.onMeasure(Int, Int) 공식문서 링크에서 확인할 수 있다.
- onMeasure() 로 전달 받는 인자값들은 CustomView 가 렌더링 될 때 필요한 width 와 height 값이다. 해당 값의 크기에서 그려지는 게 좋지만, 원한다면 해당 크기를 넘겨도 된다. 다만, 이 때는 스크롤, 클리핑 등의 추가적인 보완이 필요하다.
- width 와 height 가 계산되어 전달 받으면, setMeasuredDimension() 메소드를 호출하여 해당 값들을 다시 전달해줘야한다. 이 과정에서 오류가 발생한다면, 잘못된 값이 전달되어서 그럴 것이다.
3-2) View Methods
View 를 상속받는 CustomView 가 오버라이드 할 수 있는 메소드를 정리하면 다음 표와 같다.
Category | Methods | Description |
Creation | Constructors | 코드에서 생성되거나, Layout 파일에서 inflate 되면 실행되는 생성자 함수이다. Layout 파일에서 inflate 될 때는, 생성자에서 전달 받을 Attribute 들을 파싱하고 프로퍼티로 저장하는 과정이 필요하다. |
onFinishInflate() | XML 에서 해당 View 가 모두 inflate 되면 호출되는 메소드 | |
Layout | onMeasure(int, int) | View 가 사용할 수 있는 최대 크기 값을 전달해주는 메소드 |
onLayout(boolean, int, int, int, int) | View 가 하위 객체들의 크기와 위치를 할당해야 할 때 호출되는 메소드 | |
onSizeChanged(int, int, int, int) | View 의 크기가 바뀔 때 호출되는 메소드 | |
Drawing | onDraw(Canvas) | View 위에 렌더링 해야할 것이 있을 때 호출되는 메소드 |
Event Processing | onKeyDown(int, KeyEvent) | 새로운 Key 이벤트가 발생했을 때 호출되는 메소드 |
onKeyUp(int, KeyEvent) | Key Up 이벤트가 발생했을 때 호출되는 메소드 | |
onTrackballEvent(MotionEvent) | Trackball 의 Motion 이벤트가 발생했을 때 호출되는 메소드 | |
onTouchEvent(MotionEvent) | 터치 스크린에 Motion 이벤트가 발생했을 때 호출되는 메소드 | |
Focus | onFocusChanged(boolean, int, Rect) | View 가 Focus 를 얻거나 잃었을 때 호출되는 메소드 |
onWindowFocusChanged(boolean) | View 를 포함하고 있는 Window 가 Focus 를 얻거나 잃었을 때 호출되는 메소드 | |
Attaching | onAttachedToWindow() | View 가 Window 에 Attach 됐을 때 호출되는 메소드 |
onDetachedFromWindow() | View 가 Window 로부터 Detach 됐을 때 호출되는 메소드 | |
onWindowVisibilityChanged(int) | View 를 포함하고 있는 Window 의 Visibility 속성 값이 변했을 때 호출되는 메소드 |
4. Compound Controls
뷰를 완전히 커스터마이징 하는 방법과 달리, 이미 구현된 뷰들을 결합하여 하나의 그룹으로 생성할 수 있다. 이를 Compound Component( Compound Control ) 이라 한다. Compound Component 를 만드는 방법은 다음과 같다.
- Layout 을 상속받은 새로운 클래스 생성.
- 새로운 클래스의 생성자에서 부모클래스의 생성자로 알맞는 값들을 전달. 그 후에 새로운 클래스의 내부에서 사용하는 뷰들을 설정할 수 있음.
- 결합한 내부 뷰들의 이벤트 리스너 추가.
- 프로퍼티의 접근자와 수정자 추가
- View 가 아니라 Layout 단계의 클래스를 상속 받았을 경우, onDraw() 와 onMeasure() 를 반드시 오버라이드 할 필요 없음. 그러나, 원한다면 오버라이드를 해도 상관 없음.
- 추가적으로 "on~" 으로 시작하는 메소드들을 오버라이드 해도 됨.
순서는 "Fully Customized Components" 를 만드는 단계와 거의 유사하다. 즉, Fully Customized 이던, Compund 이던, 자신만의 View 를 만드는 방법은 거의 유사하다는 것을 느낄 수 있다.
5. Modifying an Existing View Type
지금까지 커스텀뷰를 만드는 방법 2가지를 소개했다. 첫번째는 Fully Customized 하는 방법으로, 커스텀뷰의 모양부터 기능까지 모두 커스텀을 하는 방법이었다. 두번째는 Compound 방법으로, 원하는 두 가지의 위젯을 섞어서 하나의 커스텀뷰로 구성하는 것이었다. 마지막으로, 커스텀 뷰를 만드는 더 쉬운 방법에 대해 정리한다. 본 방법은 "만들고자 하는 커스텀뷰의 기능이 이미 구현된 위젯들 중에서 상당히 비슷한 위젯이 존재할 때" 가능한 방법이다. 비슷한 기능을 가지고 있는 뷰를 상속받아서 수정하고 싶은 부분만 변경하여 커스텀뷰를 만드는 것이다. Fully Customized 방법으로 원하는 기능들을 처음부터 끝까지 다 구현할 수도 있지만, 해당 방법을 사용하면 원하는 기능들을 손쉽게 얻을 수 있어 짧은 시간에 원하는 커스텀뷰를 만들 수 있다.
Android 공식 문서는 위 방법을 NotePad App 으로 예시를 들어 설명한다. 정확히는 첨부된 링크 안에서 NoteEditor.java 파일에 있는 LinedEditText 클래스를 예시로 사용한다.
....
/**
* Defines a custom EditText View that draws lines between each line of text that is displayed.
*/
public static class LinedEditText extends EditText {
private Rect mRect;
private Paint mPaint;
// This constructor is used by LayoutInflater
public LinedEditText(Context context, AttributeSet attrs) {
super(context, attrs);
// Creates a Rect and a Paint object, and sets the style and color of the Paint object.
mRect = new Rect();
mPaint = new Paint();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(0x800000FF);
}
/**
* This is called to draw the LinedEditText object
* @param canvas The canvas on which the background is drawn.
*/
@Override
protected void onDraw(Canvas canvas) {
// Gets the number of lines of text in the View.
int count = getLineCount();
// Gets the global Rect and Paint objects
Rect r = mRect;
Paint paint = mPaint;
/*
* Draws one line in the rectangle for every line of text in the EditText
*/
for (int i = 0; i < count; i++) {
// Gets the baseline coordinates for the current line of text
int baseline = getLineBounds(i, r);
/*
* Draws a line in the background from the left of the rectangle to the right,
* at a vertical position one dip below the baseline, using the "paint" object
* for details.
*/
canvas.drawLine(r.left, baseline + 1, r.right, baseline + 1, paint);
}
// Finishes up by calling the parent method
super.onDraw(canvas);
}
}
....
5-1) The Definition
public static class LinedEditText extends EditText
LinedEditText 는 NoteEditor Activity 의 내부 클래스로 정의되어 있다. 하지만, "public" 이기 때문에 NoteEditor 의 외부에서 필요할 때 LinedEditText 로 접근이 가능하다.
그리고 "static" 이기 때문에, NoteEditor 를 인스턴스하지 않아도 LinedEditText 를 생성할 수 있다. 해당 구현 방법은 외부 클래스에서 LinedEditText 의 상태에 액세스 할 필요가 없고, LinedEditText 를 작게 유지하고 싶으며, 다른 클래스에서 쉽게 사용할 수 있도록 만들고 싶을 때 사용하는 더 깔끔한 내부 클래스 구현 방법이다.
"EditText" 를 상속 받았으므로, 작업이 끝나면 새 클래스는 보통 EditText 로 대체할 수 있다.
5-2) Class Initialization
// This constructor is used by LayoutInflater
public LinedEditText(Context context, AttributeSet attrs) {
super(context, attrs);
....
}
커스텀뷰는 모두 super 생성자 메소드를 호출해야한다. 파라미터가 없는 디폴트 생성자는 존재하지 않으며, Context 와 Attribute 파라미터들을 부모 클래스로 전달해줘야한다.
5-3) Overriden Methods
/**
* This is called to draw the LinedEditText object
* @param canvas The canvas on which the background is drawn.
*/
@Override
protected void onDraw(Canvas canvas) {
....
// Finishes up by calling the parent method
super.onDraw(canvas);
}
LinedEditText 코드를 보면 onDraw() 메소드 하나만 오버라이드 한 것을 볼 수 있다. 그러나 onDraw() 만 오버라이드가 가능한 것은 아니다. 필요에 따라 다른 메소드들을 오버라이드 할 수 있다.
5-4) Use the Custom Component
이제 위 과정을 거쳐 만들어진 커스텀뷰는 XML 에서 어떻게 사용되는지 알아보자. 먼저, View 요소를 추가하고, 하위 속성 중에 "class" 속성의 값을 커스텀뷰 경로를 넣어준다.
<view xmlns:android="http://schemas.android.com/apk/res/android"
class="com.example.android.notepad.NoteEditor$LinedEditText"
android:id="@+id/note"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:padding="5dp"
android:scrollbars="vertical"
android:fadingEdge="vertical"
android:gravity="top"
android:textSize="22sp"
android:capitalize="sentences"
/>
"class" 속성 값으로 LinedEditText 의 패키지명을 풀네임으로 넣어준다. 그리고, LinedEditText 는 NoteEditor 클래스의 내부 클래스로 정의되었기 때문에 "$" 를 사용해서 내부 클래스임을 명시하여 패키지명을 완성시킨다.
이와 반대로, 내부 클래스로 만들지 않고, 독립적인 클래스로 커스텀뷰를 만들었을 때는 LinedEditText 의 패키지명을 위젯명처럼 사용할 수 있다.
<com.example.android.notepad.LinedEditText
id="@+id/note"
... />
위 코드와 같이 내부 클래스가 아니라 분리된 클래스로 LinedEditText 를 구현했다면 위젯이름처럼 사용할 수 있다. 이 때는 반대로 "class" 속성 값을 사용하는 방식이 통하지 않는다. 이에 추가로, XML 에서 커스텀뷰를 추가하고, XML 코드 상에서 Attribute 값을 추가할 수 있다. XML 에서 입력한 Attribute 값을 커스텀뷰의 코드에서 받아서 파싱하는 방법은 다음 글에서 기술한다.
6. 정리
Android 공식문서를 통해 CustomView 를 만드는 방법은 3가지가 있다는 것을 알게 되었다.
- Fully Customized Component : View 를 그리는 것부터 동작까지 모두 구현하는 방법
- Compound Component : 원하는 기능들이 여러 위젯에 각각 있을 경우, 하나로 합쳐서 만드는 방법
- Modifying an Existing View Type : 원하는 기능들이 대부분 있는 하나의 위젯을 확장시키는 방법
처음에는 1번의 Fully Customized 방식만이 커스텀뷰를 만드는 방법이라 생각했다. 하지만, 더 쉽고 효율적인 방법들도 있었다. 그러나, 필자가 목표로 하는 Radar Chart 나 악보 뷰는 오로지 1번의 방법만으로 가능할 것 같다. 그래서, 다음 글부터는 1번 방법에 대해 공부하고 정리해보고자 한다.
'Android Developer' 카테고리의 다른 글
RadarChartView 직접 만들기 : 2. Creating a View Class (0) | 2022.10.12 |
---|---|
Androrid 기기 정보 얻기 (1) | 2022.09.23 |
Android Zoom Video SDK : 3. Essential Guides (2) (0) | 2022.08.10 |
Android Zoom Video SDK : 2. Essential Guides (1) (0) | 2022.08.05 |
Android Zoom Video SDK : 1. Overview (0) | 2022.07.28 |