이전 글에서는 CustomView 클래스를 만드는 방법에 대해 정리했다. 이번 글에서는 CustomView 에서 직접 그려보는 방법에 대해 정리한다. 본 글은 Android Developer 공식 문서를 기반으로 작성한다.
출처 : https://developer.android.com/develop/ui/views/layout/custom-views/custom-drawing
1. Override onDraw()
커스텀뷰를 그리는 가장 중요한 과정은 "onDraw()" 메소드를 오버라이드 하는 것이다. "onDraw()" 메소드의 파라미터는 Canvas 객체가 사용된다. Canvas 클래스는 텍스트, 선, 비트맵 이미지 등 그래픽 요소들을 그릴 수 있는 메소드들을 정의하고 있다. 즉, 커스텀뷰에서 원하는 그림을 그리고 싶다면, 오버라이드한 onDraw() 메소드의 파라미터로 전달받은 Canvas 객체의 다양한 메소드들을 통해 그릴 수 있다. 그리고 Canvas 의 메소드들을 호출하기 이전에 "Paint" 객체를 생성하는 것이 우선이다.
2. Create Drawing Objects
"android.graphics" 프레임워크는 드로잉과 관련된 것을 두 가지로 나눈다.
- 무엇을 그릴 것인가 : Canvas
- 어떻게 그릴 것인가 : Paint
예를 들어, Canvas 는 선(Line)을 그리고, Paint 는 해당 선의 색상을 정한다. 또는, Canvas 가 사각형(Rect)을 그리고, Paint 는 해당 사격형을 채울 색상을 정한다. 즉, Canvas 가 화면에 그릴 그림의 형태를 결정한다면, Paint 는 Canvas 가 그린 그림의 색상, 스타일, 폰트 등을 결정한다.
따라서, Canvas 는 onDraw() 의 메소드를 통해 파라미터로 받을 수 있기 때문에, Paint 객체만 추가로 선언해주면 된다. 다음 예제 코드에서는 Paint 객체를 클래스의 전역변수로 선언한다.
// PieChart.kt
class PieChart(context: Context, attrs: AttributeSet) : View(context, attrs) {
...
private var textHeight = 0f
private val textColor = ResourcesCompat.getColor(resources, R.color.black, null)
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = textColor
if (textHeight == 0f) {
textHeight = textSize
} else {
textSize = textHeight
}
}
private val piePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
textSize = textHeight
}
private val shadowPaint = Paint(0).apply {
color = 0x101010
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
}
...
}
위 코드처럼 미리 전역변수로 필요한 객체를 만들어 두는 것은 중요한 최적화 과정이다. View 들은 매우 빈번하게 다시 그려지고, 많은 드로잉 객체들은 초기화 과정만으로도 가볍지 않기 때문이다. 따라서, onDraw() 메소드 안에서 Paint 객체를 생성하도록 코드를 짜는 것은 비효율적이며, 좋은 UI 로 보여질 수 없다.
3. Handle Layout Events
적절하게 커스텀뷰를 그리기 위해서, 어느 정도 크기인지 아는 것이 중요하다. 복잡한 커스텀 뷰는 종종 화면에서 커스텀뷰의 구역의 크기와 모양에 의존하여 다중 레이아웃 계산을 수행할 수도 있다. 그리고 커스텀뷰를 제작할 때는 화면에서 커스텀뷰의 크기를 가늠해서 설정하면 안된다. 안드로이드 기기들의 화면 크기와 해상도는 기기에 따라 천차만별이고, 가로모드와 세로모드에 따라 달라지기 때문이다.
View 는 크기 측정을 제어할 수 있는 메소드들을 가지고 있지만, 대부분 해당 메소드들을 오버라이드 하지 않는다. 만약 자체 제작한 커스텀뷰의 크기를 특별히 제어할 필요가 없다면, "onSizeChanged()" 메소드만 오버라이드 해주면 된다.
"onSizeChanged()" 메소드는 커스텀뷰가 최초로 크기를 할당 받았을 때 호출된다. 그 후엔 어느 이유에서든 크기에 변화가 생겼을 때 호출된다. 커스텀뷰가 그려질 때마다 다시 계산하기 보다는, onSizeChanged() 메소드 내에서 위치, 차원 등등 커스텀뷰의 크기와 관련된 값들을 계산하는 것이 좋다. 현재 예시로 만들고 있는 "PieChart" 같은 경우, 파이차트는 원형 모양의 차트를 의미하나, 파이 차트를 전체적으로 감싸는 영역은 직사각형의 형태일 것이다. "PieChart" 의 크기를 결정하는 것은 차트 자체보다는 전체적인 사각형 영역의 크기일 것이므로, 사각형 영역의 크기가 바뀔 때, "onSizeChanged()" 메소드가 호출될 것이고, 이 때 파이차트와 관련된 텍스트 영역이나 기타 비주얼 영역의 크기를 재설정하도록 코드를 짜는 것 좋다.
커스텀뷰가 크기를 할당 받을 때, 레이아웃 매니저는 해당 뷰의 패딩까지 포함하는 크기로 인식한다. 즉, 커스텀 뷰의 크기를 계산할 때, 패딩값까지도 고려를 해줘야한다. 현재 예시로 제작 중인 "PieChart" 의 코드는 다음과 같다.
// PieChart.kt
class PieChart(context: Context, attrs: AttributeSet) : View(context, attrs) {
private var textWidth = 0f
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// Account for padding
var xpad = (paddingLeft + paddingRight).toFloat()
var ypad = (paddingTop + paddingBottom).toFloat()
// Account for the label
if (showText) xpad += textWidth
val ww = w.toFloat() - xpad
val hh = h.toFloat() - ypad
// Figure out how big we can make the pie.
val diameter = Math.min(ww, hh)
}
}
만약 뷰의 레이아웃 파라미터를 더 섬세하게 컨트롤 하고 싶다면, "onMeasure()" 을 구현한다. 해당 메소드의 파라미터는 "View.MeasureSpec" 값들로, 부모 뷰가 커스텀 뷰에 원하는 크기와 해당 크기가 최대값인지 아니면 단순한 제안인지 여부를 알려준다. 최적화로서, 전달 받은 값들은 봉합된 정수형으로 저장하고 있으며, View.MeasureSpec 의 정적 메소드를 통해 봉합된 정수값들을 언팩하여 정보를 획득할 수 있다.
onMeasure() 에 대한 예시 코드는 아래와 같으며, 해당 예시 코드에서 "PieChart" 는 최대한 크게 만들고자 시도한다.
// PieChart.kt
class PieChart(context: Context, attrs: AttributeSet) : View(context, attrs) {
...
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Try for a width based on our minimum
val minW: Int = paddingLeft + paddingRight + suggestedMinimumWidth
val w: Int = resolveSizeAndState(minW, widthMeasureSpec, 1)
// Whatever the width ends up being, ask for a height that would let the pie
// get as big as it can
val minH: Int = MeasureSpec.getSize(w) - textWidth.toInt() + paddingBottom + paddingTop
val h: Int = resolveSizeAndState(minH, heightMeasureSpec, 0)
setMeasuredDimension(w, h)
}
}
위 코드에서 알아두어야할 3가지 중요 요소가 있다.
- 뷰의 크기를 계산할 때는 뷰의 패딩값을 고려해야한다. 앞에서도 언급했듯이 뷰의 패딩을 고려하는건 뷰의 책임이기 때문이다.
- "resolveSizeAndState()" 메소드는 최종적인 Width 와 Height 값을 생성한다. 그리고 onMeasure() 로 전달된 spec 에 필요한 뷰의 크기를 비교하여 적절한 View.MeasureSpec 값을 반환한다.
- onMeasure() 은 반환하는 값이 없다. 대신, 함수 내부의 마지막에는 "setMeasuredDimension()" 메소드를 호출해야한다. 만약 호출하지 않는다면, 런타임 예외가 발생한다.
4. Draw!
뷰의 크기 변경에 대한 메소드와 뷰의 크기를 결정 짓는 메소드를 오버라이드 했다. 이제는 직접 그리는 부분을 담당하는 "onDraw" 를 구현한다. 모든 뷰마다 원하는 그림이 다르므로, onDraw 구현부도 다를 것이다. 하지만, 몇가지 공통적으로 동작하는 부분이 있다.
- "drawText()" 로 텍스트를 그린다. "setTypeface()" 로 typeface(서체) 를 결정하고, "setColor()" 로 텍스트 색상을 정한다.
- "drawRect()", "drawOval()", "drawArc()" 를 사용하여 기본적인 도형을 그린다. 도형의 채울 색상, 테두리 색상 등은 "setStyle()" 로 정할 수 있다.
- "Path" 클래스를 사용하여 좀 더 복잡한 도형을 그릴 수 있다. Path 객체에 선과 커브를 추가하여 도형 모양을 정의할 수 있고, "drawPath()" 를 통해서 정의한 도형을 그린다. Path 를 사용하여 그린 도형도 "setStyle()" 을 사용하여 테두리, 채우기 색상 등을 정할 수 있다.
- "LinearGradient" 객체를 사용하여 그라데이션 효과를 줄 수 있다. "setShader()" 를 호출하여, 그라데이션 효과를 채울 도형에서 LinearGradient 를 사용할 수 있다.
- "drawBitmap()" 으로 비트맵 이미지를 그릴 수도 있다.
다양한 모양을 그릴 수 있지만, drawOval(), drawRect(), drawArc() 에 대해서만 한번 알아보도록 한다.
4-1) Oval
drawOval() 메소드를 사용해서 Oval 형태의 도형을 그릴 수 있다. drawOval() 메소드의 파라미터는 다음과 같다.
RectF 타입의 인자값과 Paint 타입의 값을 넣는 메소드가 있고, RectF 객체 대신 left, top, right, bottom 값과 Paint 객체를 넣는 메소드가 있다. Paint 객체는 본 글의 "2. Create Drawing Objects" 섹션에서 정의한 "shadowPaint" 객체를 사용하기로 한다. 남은건 RectF 객체이다. RectF 대신 left, top, right, bottom 값을 넣어도 되는데, RectF 의 생성자도 left, top, right, bottom 값을 받기 때문에 drawOval() 메소드의 파라미터를 짧게 쓰냐, 길게 쓰냐의 차이라고 볼 수 있다.
RectF 의 생성자는 다음과 같다.
위 사진에서 두번째 생성자 형태가 drawOval() 메소드의 다른 인자 형태와 같다. 그런데, left, top, right, bottom 의 단어뜻은 바로 알겠으나, 어떤 의미를 담고 있는지는 살짝 헷갈릴 수 있다. 우선, RectF 클래스의 정의를 보면 다음과 같다.
출처 : https://developer.android.com/reference/android/graphics/RectF
RectF holds four float coordinates for a rectangle. The rectangle is represented by the coordinates of its 4 edges (left, top, right, bottom). These fields can be accessed directly. Use width() and height() to retrieve the rectangle's width and height. Note: most methods do not check to see that the coordinates are sorted correctly (i.e. left <= right and top <= bottom).
의역해보면, 'RectF 는 직사각형 4개의 float 좌표로 그려지며, left, top, right, bottom 4개 모서리의 좌표로 직사각형을 이룬다.' 라고 설명하고 있다. 즉, RectF 의 생성자로 넣는 left, top, right, bottom 값은 4개의 모서리 좌표를 만들기 위한 인자값인 것이다.
drawOval() 메소드를 활용하면서, "left, top, right, bottom" 값에 대해 더 알아보자. 아래의 코드는 임의의 값으로 RectF 를 생성하여 drawOval() 메소드를 호출한 코드와 결과이다.
// PieChart.kt
class PieChart(context: Context, attrs: AttributeSet) : View(context, attrs) {
...
private val shadowBounds = RectF(200F, 100F, 600F, 600F)
private val shadowPaint = Paint(0).apply {
color = ResourcesCompat.getColor(resources, R.color.purple_200, null)
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
}
...
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.apply {
drawOval(shadowBounds, shadowPaint)
}
}
}
shadowPaint 객체를 초기화할 때, maskFilter 값을 BlurMaskFilter 로 설정하여 전체적으로 블러효과가 적용된 것으로 보인다. 그리고 color 값도 지정한 Color 값으로 설정된 것을 확인할 수 있다. 위 사진에서 RectF 의 값은 (200F, 100F, 600F, 600F) 이다. 이번에는 RectF 의 값을 살짝 조정하여 어떤 모양으로 변하는지 관찰해보자.
private val shadowBounds = RectF(100F, 100F, 600F, 600F)
타원 모양에서 원 모양으로 바뀐 것을 확인할 수 있다. left, top, right, bottom 값은 좌표 값이므로, left 의 값이 200F 에서 100F 로 작아짐에 따라, 가로 방향으로 크기가 커진 것이다. 정리하자면 다음과 같다.
View 가 그려지는 전체 영역의 왼쪽 상단을 기준점으로 하여, 절대값 거리만큼 표시하는 것을 left, top, right, bottom 값이라 볼 수 있다. 4개의 값을 통해 4개의 교차점을 만들 수 있고, 그 교차점들로 하나의 직사각형을 그려서 그 안에 타원을 그리는 것이 "drawOval()" 메소드이다.
4-2) Rect
drawRect() 메소드는 drawOval() 과 비슷한 생성자를 가지고 있다. RectF 객체와 Paint 객체를 생성자의 파라미터로 넣어주면 된다. 위의 drawOval() 에서 썼던 RectF 와 Paint 객체를 그대로 재사용해서 drawRect() 메소드를 호출해보았다.
// PieChart.kt
class PieChart(context: Context, attrs: AttributeSet) : View(context, attrs) {
...
private val shadowBounds = RectF(100F, 100F, 600F, 600F)
private val shadowPaint = Paint(0).apply {
color = ResourcesCompat.getColor(resources, R.color.purple_200, null)
maskFilter = BlurMaskFilter(8f, BlurMaskFilter.Blur.NORMAL)
}
...
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.apply {
drawRect(shadowBounds, shadowPaint)
}
}
}
4-3) Arc
drawOval() 과 drawRect() 에서 Oval 과 Rect 는 Drawable 파일을 만들면서 자주 써봤기에, 어떤 도형이 나올지 바로 예상이 가능하다. 하지만, 이번에 정리할 "drawArc()" 의 Arc 는 어떤 도형일지 모를 수도 있다. "drawArc()" 는 부채꼴 모양의 도형을 그리는 메소드이다. 부채꼴은 Oval, Rect 와 다르게 각도를 설정해야한다. 따라서, "drawArc()" 의 파라미터는 조금 다르다.
drawArc() 메소드는 2가지로 오버로딩 되어있으며, 파라미터를 보면 drawOval(), drawRect() 의 파라미터와 유사한 부분이 있다. 바로, 첫 파라미터는 RectF 타입이고, 마지막 파라미터는 Paint 타입이라는 것이다. 그렇다면 drawArc() 메소드의 파라미터 중 "startAngle", "sweepAngle", "useCenter" 가 어떤 값인지에 대해서만 정리를 해보고자 한다.
결론부터 말하자면, startAngle 은 시작점의 각도, sweepAngle 은 시작점으로부터 종료점까지의 각도, useCenter 는 중심을 사용할지 말지의 여부를 의미한다. 우선, startAngle 을 -30F, sweepAngle 을 220F, useCenter 를 true 로 하여 drawArc() 를 호출한 결과이다.
이번에는 위 설정값에서 startAngle 을 30F 로 바꾼 출력화면을 확인해보자.
두 출력화면을 통해 startAngle 과 sweepAngle 이 어떤 의미인지 그림으로 정리하면 다음과 같다.
가로로 그어놓은 점선은 각도의 기준선을 의미하며, 기준선을 기준으로 위쪽으로 -(minus) 각도, 아래쪽으로 +(plus) 각도 값을 나타낸다. 마지막으로, useCenter 는 어떤 기능인지 확인해본다.
두 그림을 비교해보면, 중심을 사용한다는 것은 중심점으로부터 회전한 모양을 나타낸다는 것을 의미한다. useCenter 가 false 값일 경우에는 중심점을 거치지 않고, 시작점과 종료점을 그대로 직선으로 이어서 도형을 마무리한 것으로 보인다.
5. Apply Graphics Effects
Android 12 (API level 31) 부터 "RenderEffect" 클래스가 추가되었다. 해당 클래스는 블러, 색상 필터, 쉐이더 효과 등등 공용으로 사용될만 한 그래픽 효과를 적용할 수 있게 해준다. 각 효과들은 체인처럼 연계하거나, 혼합하여 사용할 수 있다. 다만, 기기의 성능에 따라 적용이 안될 수도 있다.
view.setRenderEffect(RenderEffect.createBlurEffect(radiusX, radiusY, SHADER_TILE_MODE))
'Android Developer' 카테고리의 다른 글
Android Vitals 와 Firebase Crashlytics 연동 (0) | 2022.11.02 |
---|---|
Firebase Console 과 Android App 연동 (0) | 2022.11.02 |
RadarChartView 직접 만들기 : 2. Creating a View Class (0) | 2022.10.12 |
Androrid 기기 정보 얻기 (1) | 2022.09.23 |
RadarChartView 직접 만들기 : 1. CustomView Overview (0) | 2022.08.18 |