구글에 치면 그래프용 api가 있다 하지만 디자인은 바꿀수 없음,,,눈물남
그래서 canvas를 사용하여 직접 구현할 수 밖에 없었다,,,
https://jyys.tistory.com/11 블로그 참고 함
결과물 이미지)

차트에 표시할 데이터를 정의, RadarChartData라는 클래스를 만들어서 데이터를 저장하고, CharacteristicType에 표시할 5가지 특성을 정함
data class RadarChartData(
val type: CharacteristicType,
val value: Float
)
enum class CharacteristicType(val displayName: String) {
AGILITY("민첩성"),
BALANCE("평형성"),
ENDURANCE("근지구력"),
FLEXIBILITY("유연성"),
SPEED("순발력")
}
private val linePaint = Paint().apply {
color = Color.parseColor("#C8C8C8") //색상
style = Paint.Style.STROKE // 테두리 스타일
strokeWidth = 2f // 선 두께 (2픽셀)
}
private val firstLinePaint = Paint().apply {
color = Color.parseColor("#0C4E8C")
style = Paint.Style.STROKE
strokeWidth = 5f
}
private val textPaintNomal = Paint().apply {
color = Color.parseColor("#0C4E8C")
textSize = 14f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = ResourcesCompat.getFont(context, R.font.pretendard_medium)
}
private val textPaintMaxPoint = Paint().apply {
color = Color.parseColor("#56A02F")
textSize = 16f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = ResourcesCompat.getFont(context, R.font.pretendard_bold)
}
private val textPaintMinPoint = Paint().apply {
color = Color.parseColor("#FF4747")
textSize = 16f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = ResourcesCompat.getFont(context, R.font.pretendard_bold)
}
private val lastWeekPaint = Paint().apply {
color = Color.parseColor("#9A9A9A")
style = Paint.Style.FILL_AND_STROKE
alpha = 100
}
private val thisWeekPaint = Paint().apply {
color = Color.parseColor("#62ADCE")
style = Paint.Style.FILL_AND_STROKE
alpha = 100
}
private var lastWeekData: List<RadarChartData>? = null
private var thisWeekData: List<RadarChartData>? = null
fun setDataLists(lastWeekData: List<RadarChartData>, thisWeekData: List<RadarChartData>) {
this.lastWeekData = lastWeekData
Log.d("chart","lastWeekData => $lastWeekData")
this.thisWeekData = thisWeekData
Log.d("chart","thisWeekData => $thisWeekData")
invalidate() // 뷰를 다시 그립니다
}
// 아이콘 비트맵 추가
private val thumbsUpIcon = vectorToBitmap(context, R.drawable.thumbs_up)
// private val chearUpIcon = BitmapFactory.decodeResource(resources, R.drawable.cheer_up)
private val chearUpIcon = vectorToBitmap(context, R.drawable.cheer_up)
//차트에 보여질 순서 맨 위부터 시계방향
private var chartTypes = arrayListOf(
CharacteristicType.quickness,
CharacteristicType.muscularEndurance,
CharacteristicType.equilibrium,
CharacteristicType.pliability,
CharacteristicType.agility
)
val cx = width / 2f
val cy = height / 2f
val radius = min(cx, cy) * 0.8f * 1f
val radian = PI.toFloat() * 2 / 5
val step = 5
val heightMaxValue = min(cx, cy) * 0.7f * 1f
val heightStep = heightMaxValue / step
중심에서 시작해 다각형 단계별 레이어를 그린다
1. startX와 startY를 중심으로 초기화.
2. 각 단계의 시작점에서 transformRotate를 사용해 5등분 회전 좌표(stopPoint)를 계산
3. 첫 번째 단계(i == 0): 굵은 선(firstLinePaint)으로 그리기
4. 나머지 단계(i > 0): 얇은 선(linePaint)으로 그리기
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)
if (i==0) {
canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, firstLinePaint)
} else {
canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, linePaint)
}
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
)
}
}
각 단계별 기준값 표시는 현재 내 프로젝트에선 주석처리했지만 주석을 풀면 선에 기준값이 표시됨
이것 또한 현재 프로젝트에선 주석처리했지만 중심점에서 꼭짓점까지 연결하는 선을 그리는 코드이다
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, linePaint)
startX = stopPoint.x
startY = stopPoint.y
}
각 꼭짓점에 텍스트(특성 이름 및 값)와 최고값과 최저값에 아이콘 추가함
텍스트 위치와 아이콘 위치 계산를 계산하여 위치 조정함
startX = cx
startY = (cy - 300f) * 0.7f
r = 0f
path.reset()
chartTypes.forEach { type ->
val point = transformRotate(r, startX, startY, cx, cy)
val textHeight = textPaintNomal.fontMetrics.descent - textPaintNomal.fontMetrics.ascent // 텍스트의 높이
//꼭지점에 붙어 있는 글씨 위치
var adjustedY = 0f
// var iconY = 0f
if (type.name.equals("pliability") || type.name.equals("agility")) {
adjustedY = point.y - (textHeight / 2) - 7f
// iconY = point.y - (textHeight * 3) - 10f
} else if(type.name.equals("quickness")) {
adjustedY = point.y - (textHeight / 7)
// iconY = point.y - (textHeight * 2.5f) - 10f
} else {
adjustedY = point.y + textHeight + 5f
// iconY = adjustedY + (textHeight / 2) + 1f // 텍스트 바로 아래에 아이콘 배치
}
//아이콘 높이와 위치
val iconThumbsHeight = thumbsUpIcon.height
val iconThumbsY = adjustedY - (textHeight / 2) - iconThumbsHeight - 25f
val iconCheerUpHeight = thumbsUpIcon.height
val iconCheerUpY = adjustedY - (textHeight / 3) - iconCheerUpHeight - 10f
val maxValue = thisWeekData?.maxOfOrNull { it.value } ?: 0
val maxType = thisWeekData?.firstOrNull { it.value == maxValue }
val minValue = thisWeekData?.minOfOrNull { it.value } ?: 0
val minType = thisWeekData?.firstOrNull { it.value == minValue }
//아이콘 붙이기 && 강조 텍스트 설정
val paint = when {
maxType != null && type.name == maxType.type.toString() -> {
canvas.drawBitmap(thumbsUpIcon, point.x - (thumbsUpIcon.width / 2), iconThumbsY, null)
textPaintMaxPoint
}
minType != null && type.name == minType.type.toString() -> {
canvas.drawBitmap(chearUpIcon, point.x - (chearUpIcon.width / 2), iconCheerUpY, null)
textPaintMinPoint
}
else -> textPaintNomal
}
canvas.drawText(
type.value,
point.x,
adjustedY,
paint
)
r += radian
lastWeekData?.let {
drawRadarData(canvas, it, cx, cy, radius, heightMaxValue ,lastWeekPaint,false)
}
// 이번 주 데이터 그리기
thisWeekData?.let {
drawRadarData(canvas, it, cx, cy, radius, heightMaxValue,thisWeekPaint,true)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 중심점 좌표
val cx = width / 2f
val cy = height / 2f
val radius = min(cx, cy) * 0.8f * 1f // 반경 설정
// 각도 단계 설정 (각도를 5등분)
val radian = PI.toFloat() * 2 / 5
val step = 5
val heightMaxValue = min(cx, cy) * 0.7f * 1f
val heightStep = heightMaxValue / step // 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)
if (i==0) {
canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, firstLinePaint)
} else {
canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, linePaint)
}
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, linePaint)
//
// startX = stopPoint.x
// startY = stopPoint.y
// }
// 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
startX = cx
startY = (cy - 300f) * 0.7f
r = 0f
path.reset()
chartTypes.forEach { type ->
val point = transformRotate(r, startX, startY, cx, cy)
val textHeight = textPaintNomal.fontMetrics.descent - textPaintNomal.fontMetrics.ascent // 텍스트의 높이
//꼭지점에 붙어 있는 글씨 위치
var adjustedY = 0f
// var iconY = 0f
if (type.name.equals("pliability") || type.name.equals("agility")) {
adjustedY = point.y - (textHeight / 2) - 7f
// iconY = point.y - (textHeight * 3) - 10f
} else if(type.name.equals("quickness")) {
adjustedY = point.y - (textHeight / 7)
// iconY = point.y - (textHeight * 2.5f) - 10f
} else {
adjustedY = point.y + textHeight + 5f
// iconY = adjustedY + (textHeight / 2) + 1f // 텍스트 바로 아래에 아이콘 배치
}
//아이콘 높이와 위치
val iconThumbsHeight = thumbsUpIcon.height
val iconThumbsY = adjustedY - (textHeight / 2) - iconThumbsHeight - 25f
val iconCheerUpHeight = thumbsUpIcon.height
val iconCheerUpY = adjustedY - (textHeight / 3) - iconCheerUpHeight - 10f
//최대값 최소값 찾기
val maxValue = thisWeekData?.maxOfOrNull { it.value } ?: 0
val maxType = thisWeekData?.firstOrNull { it.value == maxValue }
val minValue = thisWeekData?.minOfOrNull { it.value } ?: 0
val minType = thisWeekData?.firstOrNull { it.value == minValue }
//아이콘 붙이기 && 강조 텍스트 설정
val paint = when {
maxType != null && type.name == maxType.type.toString() -> {
canvas.drawBitmap(thumbsUpIcon, point.x - (thumbsUpIcon.width / 2), iconThumbsY, null)
textPaintMaxPoint
}
minType != null && type.name == minType.type.toString() -> {
canvas.drawBitmap(chearUpIcon, point.x - (chearUpIcon.width / 2), iconCheerUpY, null)
textPaintMinPoint
}
else -> textPaintNomal
}
canvas.drawText(
type.value,
point.x,
adjustedY,
paint
)
r += radian
// val maxValue = thisWeekData?.maxOfOrNull { it.value } ?: 0
// val maxType = thisWeekData?.firstOrNull { it.value == maxValue }
//
// val minValue = thisWeekData?.minOfOrNull { it.value } ?: 0
// val minType = thisWeekData?.firstOrNull { it.value == minValue }
//
// if (maxType != null) {
// if (type.name == maxType.type.toString()) {
// canvas.drawBitmap(thumbsUpIcon, point.x - (chearUpIcon.width / 2), iconY, null)
// }
// }
// if (minType != null) {
// if (type.name == minType.type.toString()) {
// canvas.drawBitmap(chearUpIcon, point.x - (chearUpIcon.width / 2), iconY, null)
// }
// }
}
// 지난 주 데이터 그리기
lastWeekData?.let {
drawRadarData(canvas, it, cx, cy, radius, heightMaxValue ,lastWeekPaint,false)
}
// 이번 주 데이터 그리기
thisWeekData?.let {
drawRadarData(canvas, it, cx, cy, radius, heightMaxValue,thisWeekPaint,true)
}
}
// private fun setIcon(canvas: Canvas, data: List<RadarChartData>, maxValue :Int, minValue:Int) {
// val point = transformRotate(r, startX, startY, width / 2f, height / 2f)
// val textHeight = textPaintNomal.fontMetrics.descent - textPaintNomal.fontMetrics.ascent
// //아이콘용
// var minIconY = 0f
// var maxIconY = 0f
// var selectIcon : Bitmap? = null
// var selectType = ""
//
// // 최소값에 해당하는 아이콘 처리
// data.forEach { item ->
// if (item.value == minValue) {
// selectType = item.type.toString()
// selectIcon = chearUpIcon
//
// minIconY = when {
// selectType == "pliability" || selectType == "agility" -> point.y - (textHeight * 3) - 10f
// selectType == "quickness" -> point.y - (textHeight * 2.5f) - 10f
// else -> point.y + (textHeight / 2) + 1f
// }
// Log.d("TAG", "minValue iconY => $minIconY")
// }
//
// if (item.value == maxValue) {
// selectType = item.type.toString()
// selectIcon = thumbsUpIcon
//
// maxIconY = when {
// selectType == "pliability" || selectType == "agility" -> point.y - (textHeight * 3) - 10f
// selectType == "quickness" -> point.y - (textHeight * 2.5f) - 10f
// else -> point.y + (textHeight / 2) + 1f
// }
// Log.d("TAG", "maxValue iconY => $maxIconY")
// }
// }
//
// // 아이콘 그리기
// selectIcon?.let { icon ->
// if (minValue != maxValue) {
// canvas.drawBitmap(icon, point.x , minIconY, null)
// canvas.drawBitmap(icon, point.x, maxIconY, null)
// }
// }
//
// }
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)
}
private fun drawRadarData(
canvas: Canvas, data: List<RadarChartData>,
cx: Float, cy: Float, radius: Float, heightMaxValue: Float, paint: Paint, thisWeekData: Boolean
)
val path = Path()
val angleStep = 2 * Math.PI / data.size
var maxValue = 0
var minValue = Int.MAX_VALUE
var maxIndex = 0
var minIndex = 0
data.forEachIndexed { i, radarData ->
val valueRadius = heightMaxValue * (radarData.value / 100f)
val angle = i * angleStep - Math.PI / 2
val x = cx + (valueRadius * Math.cos(angle)).toFloat()
val y = cy + (valueRadius * Math.sin(angle)).toFloat()
if (radarData.value > maxValue) {
maxValue = radarData.value
maxIndex = i
}
if (radarData.value < minValue) {
minValue = radarData.value
minIndex = i
}
if (i == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
if (thisWeekData) {
// 최대값 표시
val maxAngle = maxIndex * angleStep - PI / 2
val maxValueRadius = heightMaxValue * (maxValue / 100f)
val maxX = cx + (maxValueRadius * Math.cos(maxAngle)).toFloat()
val maxY = cy + (maxValueRadius * Math.sin(maxAngle)).toFloat()
val greenCirclePaint = Paint().apply {
color = Color.parseColor("#56A02F") // 초록색
style = Paint.Style.FILL
}
canvas.drawCircle(maxX, maxY, 25f, greenCirclePaint)
// 최소값 표시
val minAngle = minIndex * angleStep - PI / 2
val minValueRadius = heightMaxValue * (minValue / 100f)
val minX = cx + (minValueRadius * Math.cos(minAngle)).toFloat()
val minY = cy + (minValueRadius * Math.sin(minAngle)).toFloat()
val redCirclePaint = Paint().apply {
color = Color.parseColor("#FF4747") // 빨간색
style = Paint.Style.FILL
}
canvas.drawCircle(minX, minY, 15f, redCirclePaint)
data.forEachIndexed { i, radarData ->
if (i != maxIndex && i != minIndex) {
val valueRadius = heightMaxValue * (radarData.value / 100f)
val angle = i * angleStep - Math.PI / 2
val x = cx + (valueRadius * Math.cos(angle)).toFloat()
val y = cy + (valueRadius * Math.sin(angle)).toFloat()
val blueCirclePaint = Paint().apply {
color = Color.parseColor("#62ADCE") // 파란색
style = Paint.Style.FILL
}
canvas.drawCircle(x, y, 18f, blueCirclePaint)
}
}
위 코드 한번에 보여주면 아래와 같음
private fun drawRadarData(
canvas: Canvas, data: List<RadarChartData>,
cx: Float, cy: Float, radius: Float,heightMaxValue:Float, paint: Paint, thisWeekData:Boolean
) {
val path = Path()
val angleStep = 2 * Math.PI / data.size
var maxValue = 0
var minValue = Int.MAX_VALUE
var maxIndex = 0
var minIndex = 0
data.forEachIndexed { i, radarData ->
// 'heightMaxValue'에 맞춰 크기 조정
val valueRadius = heightMaxValue * (radarData.value / 100f) // 값에 따라 크기 설정
val angle = i * angleStep - Math.PI / 2 // 12시 방향으로 맞추기 위한 오프셋 적용
val x = cx + (valueRadius * Math.cos(angle)).toFloat()
val y = cy + (valueRadius * Math.sin(angle)).toFloat()
// 최대값, 최소값을 찾고 그 인덱스를 기억
if (radarData.value > maxValue) {
maxValue = radarData.value
maxIndex = i
}
if (radarData.value < minValue) {
minValue = radarData.value
minIndex = i
}
if (i == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
path.close()
canvas.drawPath(path, paint)
if (thisWeekData) {
// 최대값에 해당하는 모서리 점에 초록색 동그라미 (25f)
val maxAngle = maxIndex * angleStep - PI / 2
val maxValueRadius = heightMaxValue * (maxValue / 100f) // 최대값에 해당하는 반지름
val maxX = cx + (maxValueRadius * Math.cos(maxAngle)).toFloat()
val maxY = cy + (maxValueRadius * Math.sin(maxAngle)).toFloat()
val greenCirclePaint = Paint().apply {
color = Color.parseColor("#56A02F") // 최대값 강조용 초록색
style = Paint.Style.FILL
}
// 초록색 동그라미 그리기
canvas.drawCircle(maxX, maxY, 25f, greenCirclePaint)
// 최소값에 해당하는 모서리 점에 빨간 동그라미 (15f)
val minAngle = minIndex * angleStep - PI / 2
val minValueRadius = heightMaxValue * (minValue / 100f) // 최소값에 해당하는 반지름
val minX = cx + (minValueRadius * Math.cos(minAngle)).toFloat()
val minY = cy + (minValueRadius * Math.sin(minAngle)).toFloat()
val redCirclePaint = Paint().apply {
color = Color.parseColor("#FF4747") // 최소값 강조용 빨간색
style = Paint.Style.FILL
}
// 빨간 동그라미 그리기
canvas.drawCircle(minX, minY, 15f, redCirclePaint)
// 나머지 값에 파란 동그라미 (18f)
data.forEachIndexed { i, radarData ->
if (i != maxIndex && i != minIndex) {
val valueRadius = heightMaxValue * (radarData.value / 100f) // 값에 따라 크기 설정
val angle = i * angleStep - Math.PI / 2 // 12시 방향으로 맞추기 위한 오프셋 적용
val x = cx + (valueRadius * Math.cos(angle)).toFloat()
val y = cy + (valueRadius * Math.sin(angle)).toFloat()
val blueCirclePaint = Paint().apply {
color = Color.parseColor("#62ADCE") // 나머지 값에 파란색
style = Paint.Style.FILL
}
// 파란 동그라미 그리기
canvas.drawCircle(x, y, 18f, blueCirclePaint)
}
}
}
}
package com.airpass.aifitparentapp.utils
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PointF
import android.util.AttributeSet
import android.util.Log
import android.view.View
import androidx.core.content.res.ResourcesCompat
import com.airpass.aifitparentapp.R
import com.airpass.aifitparentapp.utils.ImageUtils.Companion.vectorToBitmap
import java.lang.Math.PI
import java.lang.Math.min
import kotlin.math.cos
import kotlin.math.sin
data class RadarChartData(
val type: CharacteristicType,
val value: Int
)
enum class CharacteristicType(val value: String) {
quickness("순발력"),
agility("민첩성"),
muscularEndurance("근지구력"),
equilibrium("평형성"),
pliability("유연성"),
}
class RadarChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val linePaint = Paint().apply {
color = Color.parseColor("#C8C8C8")
style = Paint.Style.STROKE
strokeWidth = 2f
}
private val firstLinePaint = Paint().apply {
color = Color.parseColor("#0C4E8C")
style = Paint.Style.STROKE
strokeWidth = 5f
}
private val textPaintNomal = Paint().apply {
color = Color.parseColor("#0C4E8C")
textSize = 14f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = ResourcesCompat.getFont(context, R.font.pretendard_medium)
}
private val textPaintMaxPoint = Paint().apply {
color = Color.parseColor("#56A02F")
textSize = 16f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = ResourcesCompat.getFont(context, R.font.pretendard_bold)
}
private val textPaintMinPoint = Paint().apply {
color = Color.parseColor("#FF4747")
textSize = 16f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = ResourcesCompat.getFont(context, R.font.pretendard_bold)
}
private val lastWeekPaint = Paint().apply {
color = Color.parseColor("#9A9A9A")
style = Paint.Style.FILL_AND_STROKE
alpha = 100
}
private val thisWeekPaint = Paint().apply {
color = Color.parseColor("#62ADCE")
style = Paint.Style.FILL_AND_STROKE
alpha = 100
}
private var lastWeekData: List<RadarChartData>? = null
private var thisWeekData: List<RadarChartData>? = null
fun setDataLists(lastWeekData: List<RadarChartData>, thisWeekData: List<RadarChartData>) {
this.lastWeekData = lastWeekData
Log.d("chart","lastWeekData => $lastWeekData")
this.thisWeekData = thisWeekData
Log.d("chart","thisWeekData => $thisWeekData")
invalidate() // 뷰를 다시 그립니다
}
private var path = Path()
// 아이콘 비트맵 추가
private val thumbsUpIcon = vectorToBitmap(context, R.drawable.thumbs_up)
// private val chearUpIcon = BitmapFactory.decodeResource(resources, R.drawable.cheer_up)
private val chearUpIcon = vectorToBitmap(context, R.drawable.cheer_up)
//차트에 보여질 순서 맨 위부터 시계방향
private var chartTypes = arrayListOf(
CharacteristicType.quickness,
CharacteristicType.muscularEndurance,
CharacteristicType.equilibrium,
CharacteristicType.pliability,
CharacteristicType.agility
)
var startX = 0f
var startY = 0f
var r = 0f
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 중심점 좌표
val cx = width / 2f
val cy = height / 2f
val radius = min(cx, cy) * 0.8f * 1f // 반경 설정
// 각도 단계 설정 (각도를 5등분)
val radian = PI.toFloat() * 2 / 5
val step = 5
val heightMaxValue = min(cx, cy) * 0.7f * 1f
val heightStep = heightMaxValue / step // 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)
if (i==0) {
canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, firstLinePaint)
} else {
canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, linePaint)
}
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, linePaint)
//
// startX = stopPoint.x
// startY = stopPoint.y
// }
// 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
startX = cx
startY = (cy - 300f) * 0.7f
r = 0f
path.reset()
chartTypes.forEach { type ->
val point = transformRotate(r, startX, startY, cx, cy)
val textHeight = textPaintNomal.fontMetrics.descent - textPaintNomal.fontMetrics.ascent // 텍스트의 높이
//꼭지점에 붙어 있는 글씨 위치
var adjustedY = 0f
// var iconY = 0f
if (type.name.equals("pliability") || type.name.equals("agility")) {
adjustedY = point.y - (textHeight / 2) - 7f
// iconY = point.y - (textHeight * 3) - 10f
} else if(type.name.equals("quickness")) {
adjustedY = point.y - (textHeight / 7)
// iconY = point.y - (textHeight * 2.5f) - 10f
} else {
adjustedY = point.y + textHeight + 5f
// iconY = adjustedY + (textHeight / 2) + 1f // 텍스트 바로 아래에 아이콘 배치
}
//아이콘 높이와 위치
val iconThumbsHeight = thumbsUpIcon.height
val iconThumbsY = adjustedY - (textHeight / 2) - iconThumbsHeight - 25f
val iconCheerUpHeight = thumbsUpIcon.height
val iconCheerUpY = adjustedY - (textHeight / 3) - iconCheerUpHeight - 10f
//최대값 최소값 찾기
val maxValue = thisWeekData?.maxOfOrNull { it.value } ?: 0
val maxType = thisWeekData?.firstOrNull { it.value == maxValue }
val minValue = thisWeekData?.minOfOrNull { it.value } ?: 0
val minType = thisWeekData?.firstOrNull { it.value == minValue }
//아이콘 붙이기 && 강조 텍스트 설정
val paint = when {
maxType != null && type.name == maxType.type.toString() -> {
canvas.drawBitmap(thumbsUpIcon, point.x - (thumbsUpIcon.width / 2), iconThumbsY, null)
textPaintMaxPoint
}
minType != null && type.name == minType.type.toString() -> {
canvas.drawBitmap(chearUpIcon, point.x - (chearUpIcon.width / 2), iconCheerUpY, null)
textPaintMinPoint
}
else -> textPaintNomal
}
canvas.drawText(
type.value,
point.x,
adjustedY,
paint
)
r += radian
// val maxValue = thisWeekData?.maxOfOrNull { it.value } ?: 0
// val maxType = thisWeekData?.firstOrNull { it.value == maxValue }
//
// val minValue = thisWeekData?.minOfOrNull { it.value } ?: 0
// val minType = thisWeekData?.firstOrNull { it.value == minValue }
//
// if (maxType != null) {
// if (type.name == maxType.type.toString()) {
// canvas.drawBitmap(thumbsUpIcon, point.x - (chearUpIcon.width / 2), iconY, null)
// }
// }
// if (minType != null) {
// if (type.name == minType.type.toString()) {
// canvas.drawBitmap(chearUpIcon, point.x - (chearUpIcon.width / 2), iconY, null)
// }
// }
}
// 지난 주 데이터 그리기
lastWeekData?.let {
drawRadarData(canvas, it, cx, cy, radius, heightMaxValue ,lastWeekPaint,false)
}
// 이번 주 데이터 그리기
thisWeekData?.let {
drawRadarData(canvas, it, cx, cy, radius, heightMaxValue,thisWeekPaint,true)
}
}
// private fun setIcon(canvas: Canvas, data: List<RadarChartData>, maxValue :Int, minValue:Int) {
// val point = transformRotate(r, startX, startY, width / 2f, height / 2f)
// val textHeight = textPaintNomal.fontMetrics.descent - textPaintNomal.fontMetrics.ascent
// //아이콘용
// var minIconY = 0f
// var maxIconY = 0f
// var selectIcon : Bitmap? = null
// var selectType = ""
//
// // 최소값에 해당하는 아이콘 처리
// data.forEach { item ->
// if (item.value == minValue) {
// selectType = item.type.toString()
// selectIcon = chearUpIcon
//
// minIconY = when {
// selectType == "pliability" || selectType == "agility" -> point.y - (textHeight * 3) - 10f
// selectType == "quickness" -> point.y - (textHeight * 2.5f) - 10f
// else -> point.y + (textHeight / 2) + 1f
// }
// Log.d("TAG", "minValue iconY => $minIconY")
// }
//
// if (item.value == maxValue) {
// selectType = item.type.toString()
// selectIcon = thumbsUpIcon
//
// maxIconY = when {
// selectType == "pliability" || selectType == "agility" -> point.y - (textHeight * 3) - 10f
// selectType == "quickness" -> point.y - (textHeight * 2.5f) - 10f
// else -> point.y + (textHeight / 2) + 1f
// }
// Log.d("TAG", "maxValue iconY => $maxIconY")
// }
// }
//
// // 아이콘 그리기
// selectIcon?.let { icon ->
// if (minValue != maxValue) {
// canvas.drawBitmap(icon, point.x , minIconY, null)
// canvas.drawBitmap(icon, point.x, maxIconY, null)
// }
// }
//
// }
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)
}
// 방사형 데이터 그리기 (지난 주, 이번 주 데이터 그리기)
private fun drawRadarData(
canvas: Canvas, data: List<RadarChartData>,
cx: Float, cy: Float, radius: Float,heightMaxValue:Float, paint: Paint, thisWeekData:Boolean
) {
val path = Path()
val angleStep = 2 * Math.PI / data.size
var maxValue = 0
var minValue = Int.MAX_VALUE
var maxIndex = 0
var minIndex = 0
data.forEachIndexed { i, radarData ->
// 'heightMaxValue'에 맞춰 크기 조정
val valueRadius = heightMaxValue * (radarData.value / 100f) // 값에 따라 크기 설정
val angle = i * angleStep - Math.PI / 2 // 12시 방향으로 맞추기 위한 오프셋 적용
val x = cx + (valueRadius * Math.cos(angle)).toFloat()
val y = cy + (valueRadius * Math.sin(angle)).toFloat()
// 최대값, 최소값을 찾고 그 인덱스를 기억
if (radarData.value > maxValue) {
maxValue = radarData.value
maxIndex = i
}
if (radarData.value < minValue) {
minValue = radarData.value
minIndex = i
}
if (i == 0) {
path.moveTo(x, y)
} else {
path.lineTo(x, y)
}
}
path.close()
canvas.drawPath(path, paint)
if (thisWeekData) {
// 최대값에 해당하는 모서리 점에 초록색 동그라미 (25f)
val maxAngle = maxIndex * angleStep - PI / 2
val maxValueRadius = heightMaxValue * (maxValue / 100f) // 최대값에 해당하는 반지름
val maxX = cx + (maxValueRadius * Math.cos(maxAngle)).toFloat()
val maxY = cy + (maxValueRadius * Math.sin(maxAngle)).toFloat()
val greenCirclePaint = Paint().apply {
color = Color.parseColor("#56A02F") // 최대값 강조용 초록색
style = Paint.Style.FILL
}
// 초록색 동그라미 그리기
canvas.drawCircle(maxX, maxY, 25f, greenCirclePaint)
// 최소값에 해당하는 모서리 점에 빨간 동그라미 (15f)
val minAngle = minIndex * angleStep - PI / 2
val minValueRadius = heightMaxValue * (minValue / 100f) // 최소값에 해당하는 반지름
val minX = cx + (minValueRadius * Math.cos(minAngle)).toFloat()
val minY = cy + (minValueRadius * Math.sin(minAngle)).toFloat()
val redCirclePaint = Paint().apply {
color = Color.parseColor("#FF4747") // 최소값 강조용 빨간색
style = Paint.Style.FILL
}
// 빨간 동그라미 그리기
canvas.drawCircle(minX, minY, 15f, redCirclePaint)
// 나머지 값에 파란 동그라미 (18f)
data.forEachIndexed { i, radarData ->
if (i != maxIndex && i != minIndex) {
val valueRadius = heightMaxValue * (radarData.value / 100f) // 값에 따라 크기 설정
val angle = i * angleStep - Math.PI / 2 // 12시 방향으로 맞추기 위한 오프셋 적용
val x = cx + (valueRadius * Math.cos(angle)).toFloat()
val y = cy + (valueRadius * Math.sin(angle)).toFloat()
val blueCirclePaint = Paint().apply {
color = Color.parseColor("#62ADCE") // 나머지 값에 파란색
style = Paint.Style.FILL
}
// 파란 동그라미 그리기
canvas.drawCircle(x, y, 18f, blueCirclePaint)
}
}
}
}
// private fun drawRadarData(
// canvas: Canvas, data: List<RadarChartData>,
// cx: Float, cy: Float, radius: Float,heightMaxValue:Float, paint: Paint,
// ) {
// val path = android.graphics.Path()
// val angleStep = 2 * Math.PI / data.size
//
// data.forEachIndexed { i, radarData ->
// // 'heightMaxValue'에 맞춰 크기 조정
// val valueRadius = heightMaxValue * (radarData.value / 100f) // 값에 따라 크기 설정
// val angle = i * angleStep - Math.PI / 2 // 12시 방향으로 맞추기 위한 오프셋 적용
// val x = cx + (valueRadius * Math.cos(angle)).toFloat()
// val y = cy + (valueRadius * Math.sin(angle)).toFloat()
//
// if (i == 0) {
// path.moveTo(x, y)
// } else {
// path.lineTo(x, y)
// }
// }
//
// path.close()
// canvas.drawPath(path, paint)
// }
}
// y좌표가 중심이 오도록 문자열을 그릴수 있도록하는 baseline좌표를 반환
fun Paint.FontMetrics.getBaseLine(y: Float): Float {
val halfTextAreaHeight = (bottom - top) / 2
return y - halfTextAreaHeight - top
}
<com.myApp.parentapp.utils.RadarChartView
android:id="@+id/radarChart"
android:layout_marginTop="30dp"
android:layout_width="0dp"
android:layout_height="450dp"
app:layout_constraintTop_toBottomOf="@id/lly_explain"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
나처럼 꼭 디자이너 시안에 맞추지 않아도 된다면 먼길 돌아가지 말고 아래 api 사용 추천함,,,
https://github.com/PhilJay/MPAndroidChart