이전에 작성한 chart들이 너무 간단해서, 조금 역동적이게 보일 수 있도록 간단한 애니메이션을 추가해보고자 합니다.
전에 작성했던 Radar Chart를 활용할건데요, 이 글을 꼭 보고 와주세요~
2022.06.28 - [Android/차트그리기] - 4. Android Radar Chart 직접 그리기
4. Android Radar Chart 직접 그리기
오늘은 Radar Chart(방사형 차트)를 그려볼까 합니다. 게임에서 캐릭터들의 능력치를 표시할때 흔히 보던 차트입니다. 먼저 다음과 같이 RadarChartView라는 Custom View를 생성하고, onDraw함수를 override해
jyys.tistory.com
이 Radar Chart는 5개의 특성을 가지고 있는데요, 다음 영상처럼 각 특성 값이 0부터 전달된 능력치까지 순서대로 증가하도록 애니메이션을 적용할 예정입니다.
각 특성값을 담고 있는 data class는 다음과 같이 생겼는데요, 여기서 value값을 0부터 원래 값까지 점점 증가시키면서 화면을 갱신하면 애니메이션이 동작하는 것이죠.
data class RadarChartData(
val type: CharacteristicType,
val value: Int
)
Android가 제공하는 animator중에 ValueAnimator란 것이 있는데요, 이 animator에 애니메이션 처리를 원하는 값의 범위와 시간을 주면 알아서 값을 계산해 줍니다.(구글링 하시면 많은 자료들이 있으니, 자세한 사용법은 생략하겠습니다.)
그래서 이 ValueAnimator를 이용하여 RadarChartData의 value값에 애니메이션을 적용하고자 합니다.
애니메이션을 적용하는 기본 개념은 다음과 같습니다.
- RadarChartData클래스에 scaleFactor란 변수를 별도로 두고, 이 변수를 0.0 ~ 1.0까지 ValueAnimator를 이용하여 애니메이션 처리(value값을 직접 변경하면 원래 값을 알 수 없게 되므로...)
- scaleFactor값이 변화할때마다 RadarChartView에 화면 갱신 요청
- RadarChartView의 onDraw()가 호출되면, RadarChartData의 value를 직접 참조하지 않고, value * scaleFactor를 이용하여 chart를 그림
ValueAnimator를 RadarChartData에 적용
5개 RadarChartData의 애니메이션이 동일한 시간에 시작하여 동일한 시간에 끝나는 것이 아니라
1번 value애니메이션이 시작하고 나서 조금 있다가 2번 value애니메이션이 시작하는 식으로 동작하도록 할 건데요,
data가 5개가 아니라 굉장히 많아지게 되면 그 많은 ValueAnimator를 한 곳에서 관리하기 어려워지니 RadarChartData가 직접 관리할 수 있도록 다음과 같이 구현하였습니다.
data class RadarChartData(
val type: CharacteristicType,
val value: Int
) {
// value값을 직접 바라보지 않고, 애니메이션처리되는 값을 반환하는 변수
val animatedValue: Float
get() {
return value * scaleFactor
}
private var scaleFactor = 0f
private var animator: ValueAnimator? = null
fun animate(view: View, delay: Long) {
animator?.cancel() // 중복으로 animate()를 호출하는 것을 막기 위해
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 300 // 애니메이션 동작 시간
startDelay = delay // 애니메이션 시작시간 delay
interpolator = DecelerateInterpolator() // 애니메이션 속도가 점점 느려지도록
addUpdateListener {
scaleFactor = it.animatedValue as Float
view.postInvalidateOnAnimation()
}
}
animator!!.start()
}
fun cancelAnimate() {
animator?.cancel()
}
}
1. animate() 함수는 view와 delay파라메터를 전달받아 scaleFactor변수를 0부터 1까지 duration동안 증가시킨 후, 전달된 view에 화면 갱신을 요청합니다.
- view: 각 RadarChartData의 값이 변경되면 화면을 다시 그려야 하므로, RadarChartView를 전달받아 값이 변경될 때마다 화면 갱신 요청을 하기 위한 파라미터입니다.
- delay : 각 RadarChartData의 애니메이션을 얼마나 지연시켰다가 시작할 것인지를 전달받습니다.
2. cancelAnimate() 함수는 차트 데이터가 새로 설정될 경우 이전 애니메이션은 모두 취소시켜야 하기 때문에 추가하였습니다.
RadarChartView의 onDraw() 처리 변경
이제 위에서 구현한 애니메이션을 RadarChartView에 적용해 볼 차례입니다.
먼저, setDataList() 함수를 다음과 같이 변경해 줍니다.
fun setDataList(dataList: ArrayList<RadarChartData>) {
if (dataList.isEmpty()) {
return
}
this.dataList = dataList
invalidate()
}
그냥 데이터만 설정하던 것을 다음과 같이 애니메이션을 적용하도록 수정하였습니다.
fun setDataList(dataList: ArrayList<RadarChartData>) {
if (dataList.isEmpty()) {
return
}
// 이전에 처리되고 있던 애니메이션 모두 취소
this.dataList?.forEach { data ->
data.cancelAnimate()
}
this.dataList = dataList
this.dataList?.forEachIndexed { index, data ->
// 이전 애니메이션이 시작되고 30ms씩 대기했다가 처리되도록 delay 설정
data.animate(this, (index * 30).toLong())
}
invalidate()
}
여기까지만 적용하면 애니메이션이 적용되지 않습니다.
onDraw()에서 여전히 RadarChartData의 value값을 가지고 그리고 있기 때문입니다.
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
......
// 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
chartTypes.forEach { type ->
......
// 전달된 데이터를 표시하는 path 계산
dataList?.firstOrNull { it.type == type }?.value?.let { value ->
val conValue = heightMaxValue * value / 100 // 차트크기에 맞게 변환
val valuePoint = transformRotate(r, startX, cy - conValue, cx, cy)
if (path.isEmpty) {
path.moveTo(valuePoint.x, valuePoint.y)
} else {
path.lineTo(valuePoint.x, valuePoint.y)
}
}
r += radian
}
......
}
그래서 dataList?.firstOrNull { it.type == type }?.value 부분을 다음과 같이 dataList?.firstOrNull { it.type == type }?.animatedValue 로 변경하였습니다.
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
......
// 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
chartTypes.forEach { type ->
......
// 전달된 데이터를 표시하는 path 계산
dataList?.firstOrNull { it.type == type }?.animatedValue?.let { value ->
val conValue = heightMaxValue * value / 100 // 차트크기에 맞게 변환
val valuePoint = transformRotate(r, startX, cy - conValue, cx, cy)
if (path.isEmpty) {
path.moveTo(valuePoint.x, valuePoint.y)
} else {
path.lineTo(valuePoint.x, valuePoint.y)
}
}
r += radian
}
......
}
이렇게 해서 완성된 전체 코드는 다음과 같습니다.
enum class CharacteristicType(val value: String) {
agility("민첩성"),
endurance("지구력"),
strength("근력"),
lexibility("유연성"),
intellect("지력")
}
data class RadarChartData(
val type: CharacteristicType,
val value: Int
) {
// value값을 직접 바라보지 않고, 애니메이션처리되는 값을 반환하는 변수
val animatedValue: Float
get() {
return value * scaleFactor
}
private var scaleFactor = 0f
private var animator: ValueAnimator? = null
fun animate(view: View, delay: Long) {
animator?.cancel() // 중복으로 animate()를 호출하는 것을 막기 위해
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 300 // 애니메이션 동작 시간
startDelay = delay // 애니메이션 시작시간 delay
interpolator = DecelerateInterpolator() // 애니메이션 속도가 점점 느려지도록
addUpdateListener {
scaleFactor = it.animatedValue as Float
view.postInvalidateOnAnimation()
}
}
animator!!.start()
}
fun cancelAnimate() {
animator?.cancel()
}
}
class RadarChartView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private var dataList: ArrayList<RadarChartData>? = null
// 5개의 특성을 갖도록 한다
private var chartTypes = arrayListOf(
CharacteristicType.agility,
CharacteristicType.endurance,
CharacteristicType.strength,
CharacteristicType.lexibility,
CharacteristicType.intellect
)
private val paint = Paint().apply {
isAntiAlias = true
}
private val textPaint = TextPaint().apply {
textSize = 28f
textAlign = Paint.Align.CENTER
}
private var path = Path()
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas ?: return
paint.color = Color.BLACK
paint.style = Paint.Style.STROKE
paint.strokeWidth = 1f
val radian = PI.toFloat() * 2 / 5 // 360도를 5분할한 각만큼 회전시키 위해
val step = 5 // 데이터 가이드 라인은 5단계로 표시한다
val heightMaxValue = height / 2 * 0.7f // RadarChartView영역내에 모든 그림이 그려지도록 max value가 그려질 높이
val heightStep = heightMaxValue / step // 1단계에 해당하는 높이
val cx = width / 2f
val cy = height / 2f
// 1. 단계별 가이드라인(5각형) 그리기
for (i in 0..step) {
var startX = cx
var startY = (cy - heightMaxValue) + heightStep * i
repeat(chartTypes.size) {
// 중심좌표를 기준으로 점(startX,startY)를 radian만큼씩 회전시킨 점(stopX, stopY)을 계산한다.
val stopPoint = transformRotate(radian, startX, startY, cx, cy)
canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, paint)
startX = stopPoint.x
startY = stopPoint.y
}
// 각 단계별 기준값 표시
if (i < step) {
val strValue = "${100 - 20 * i}"
textPaint.textAlign = Paint.Align.LEFT
canvas.drawText(
strValue,
startX + 10,
textPaint.fontMetrics.getBaseLine(startY),
textPaint
)
}
}
// 2. 중심으로부터 5각형의 각 꼭지점까지 잇는 라인 그리기
var startX = cx
var startY = cy - heightMaxValue
repeat(chartTypes.size) {
val stopPoint = transformRotate(radian, startX, startY, cx, cy)
canvas.drawLine(cx, cy, stopPoint.x, stopPoint.y, paint)
startX = stopPoint.x
startY = stopPoint.y
}
// 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
textPaint.textAlign = Paint.Align.CENTER
startX = cx
startY = (cy - heightMaxValue) * 0.7f
var r = 0f
path.reset()
chartTypes.forEach { type ->
val point = transformRotate(r, startX, startY, cx, cy)
canvas.drawText(
type.value,
point.x,
textPaint.fontMetrics.getBaseLine(point.y),
textPaint
)
// 전달된 데이터를 표시하는 path 계산
dataList?.firstOrNull { it.type == type }?.animatedValue?.let { value ->
val conValue = heightMaxValue * value / 100 // 차트크기에 맞게 변환
val valuePoint = transformRotate(r, startX, cy - conValue, cx, cy)
if (path.isEmpty) {
path.moveTo(valuePoint.x, valuePoint.y)
} else {
path.lineTo(valuePoint.x, valuePoint.y)
}
}
r += radian
}
// 4. 전달된 데에터를 표시하기
path.close()
paint.color = 0x7FFF0000
paint.style = Paint.Style.FILL
canvas.drawPath(path, paint)
}
fun setDataList(dataList: ArrayList<RadarChartData>) {
if (dataList.isEmpty()) {
return
}
// 이전에 처리되고 있던 애니메이션 모두 취소
this.dataList?.forEach { data ->
data.cancelAnimate()
}
this.dataList = dataList
this.dataList?.forEachIndexed { index, data ->
// 이전 애니메이션이 시작되고 30ms씩 대기했다가 처리되도록 delay 설정
data.animate(this, (index * 30).toLong())
}
invalidate()
}
// 점(x, y)를 특정 좌표(cx, cy)를 중심으로 radian만큼 회전시킨 점의 좌표를 반환
private fun transformRotate(radian: Float, x: Float, y: Float, cx: Float, cy: Float): PointF {
val stopX = cos(radian) * (x - cx) - sin(radian) * (y - cy) + cx
val stopY = sin(radian) * (x - cx) + cos(radian) * (y - cy) + cy
return PointF(stopX, stopY)
}
}
// y좌표가 중심이 오도록 문자열을 그릴수 있도록하는 baseline좌표를 반환
fun Paint.FontMetrics.getBaseLine(y: Float): Float {
val halfTextAreaHeight = (bottom - top) / 2
return y - halfTextAreaHeight - top
}
'Android > 차트그리기' 카테고리의 다른 글
4. Android Radar Chart 직접 그리기 (0) | 2022.06.28 |
---|---|
3. Android Pie Chart 직접 그리기 (0) | 2022.06.27 |
2. Android Bar Chart 직접 그리기 (0) | 2022.06.21 |
1. Android Line Chart 직접 그리기 (0) | 2022.06.17 |