카카오톡 대화방 프로필 이미지 구현해보기.
카카오톡을 하다보면 많은 카톡 대화방을 만들게 된다.
카톡 대화방은 위 이미지에서 볼 수 있듯이 "방 참여자들의 프로필 이미지로 구성"된 새로운 프로필 이미지를 갖게 된다.
예를 들어 대화방의 참여자가 나를 포함해 2명이라면 상대방의 프로필 이미지가, 3명이라면 3명의 프로필 이미지,
4명 이상부터는 무작위로 선정된 4명의 프로필 이미지가 합쳐져 새로운 프로필 이미지로 만들어진다.
지금 진행중인 [프로젝트 - 솔라로이드] 에도 사용자의 프로필과 프로필 사진이 존재한다.
그리고 여러 친구들과 함께 공유할 수 있는 사진첩을 만들 수 있다.
이 때 사진첩을 나타내는 프로필 이미지를 카카오톡의 대화방 프로필 이미지처럼 만들수 있도록 CustomView를 만들고자 한다.
AlbumProfileCustomView OverView
우선 사용자의 프로필 이미지를 임시로 다음과 같은 이미지를 사용한다.
예를 들어 공유 사진첩에 1~4명의 사용자가 참여한다면 사전칩의 프로필 이미지는 다음과 같다.
이러한 프로필 이미지를 구현하기 위해서는 다음과 같은 도형과 이미지가 필요.
- 원을 그려 넣을 사각형(배경이자 큰 틀 = Rect)
- 사각형 내부에 사용자의 프로필 이미지가 들어갈 원(프로필 이미지는 원모양으로 가다듬어진다 = Circle)
- 원에 들어갈 프로필 이미지(=Bitmap)
AlbumProfileCustomView Class (사각형, 원, 비트맵 그리기)
본격적으로 CustomView를 만들기 위해 Class를 생성한다.
나의 경우, 기존에 없던 새로운 View를 만들려고 했기 때문에
View 클래스를 그대로 구현하는 AlbumProfileView 클래스를 생성한다.
(실제 코드에서는 class 이름이 AlbumThumbnailView 로 생성)
class AlbumThumbnailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply{
style = Paint.Style.FILL
}
override fun onDraw(canvas:Canvas?) {
super.onDraw(canvas)
...
}
}
0.layout.xml 파일에 정의한 customView
.xml 에 정의한 cutomView의 widget은 다음과 같다.
<com.example.customviewexample.AlbumThumbnailView
android:id="@+id/albumView"
android:layout_width="50dp"
android:layout_height="50dp"
.../>
1.Canvas.drawRect() 이용하여 사각형 그리기.
Canvas의 drawRect()메서드는 다음과 같은 argument를 갖는다.
- 1~4번째 인자 : 사각형을 그리기 위한 (X1,Y1,X2,Y2) Float 타입.
- 5번째 인자 : 사각형의 style을 정해줄 Paint 객체.
만들고자하는 customView를 그리기 위해서
widget의 width, length와 일치하는 크기의 사각형을 그려줄 필요가 있다.
//onDraw(canvas:Canvas?)
paint.color = Color.WHITE
canvas.drawRect(0.0F, 0.0F, width.toFloat(), height.toFloat(), paint)
해당 drawRect() 메서드는 widget의 위치에 다음과 같은 사각형을 그린다.
2_1. canvas.drawCircle() 이용하여 원 그리기.
onDraw() 메서드 내에서 새로운 변수를 만들고 할당하는 것은 비효율적이다.
따라서 원을 그릴 때 필요한 반지름 radius 값을 Class 의 프로퍼티로 만들어 사용한다.
//In AlbumThumbnailView Class
private var radius : Float = 0.0f
또한 radius의 값을 view가 처음 만들어질 때 혹은 view의 size가 변화할 때 호출되는 onSizeChanged() 메서드를 이용하여 초기화한다.
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
radius = (Math.min(w,h)/4).toFloat() //너비 또는 높이 중 작은 수치에 대해 4로 나눈 값.
}
canvas.drawCircle() 메서드는 다음과 같은 argument를 갖는다.
- 1~2번째 인자 : 원의 중심점 (x,y) Float 타입.
- 3번째 인자 : 원의 반지름 (radius) Float 타입.
- 4번째 인자 : 원의 style을 정해줄 Paint 객체
위에서 만들어둔 사각형 내부에 원을 그리려고 한다.
//In onDraw(canvas:Canvas?)
paint.color = Color.RED //원이 그려지는 걸 확인하기 위해 빨간색으로 칠하기
canvas!!.drawCircle((width/2).toFloat(), (height/2).toFloat(), radius, paint)
해당 drawCircle() 메서드는 다음과 같은 원을 그린다.
2_2. 여러가지 원 그리기.
하나의 원이 아니라 카카오톡 대화방 프로필 이미지 처럼 여러가지 원을 동시에 그려야 한다.
따라서 원의 중심점 (x,y) 이 담긴 Collection 을 이용할 수 있다.
typealias CC = Pair<Float, Float>
private val thumbnailList = listOf(
listOf(CC(0.5F, 0.5F)),
listOf(CC(0.35F, 0.35F), CC(0.65F, 0.65F)),
listOf(CC(0.5F, 0.28F), CC(0.27F, 0.70F), CC(0.73F, 0.70F)),
listOf(CC(0.25F, 0.25F), CC(0.75F, 0.25F), CC(0.25F, 0.75F), CC(0.75F, 0.75F))
)
(실제 코드에서는 width와 height가 100으로 고정되어 있었기에 수치가 다를 수 있다. (width * 0.5), (height * 0.5))
collection의 값과 참여자 수에 따라 반복문을 사용하여 여러 위치에 원을 한꺼번에 그릴 수 있다.
//In onDraw()
//사각형 그리기. canvas.drawRect()
...
//원 그리기. canvas.drawCircle()
...
//여러가지 원 그리기
paint.color = Color.RED
for(i in 0 until 참여자의 수) {
canvas!!.drawCircle(list[참여자의 수 - 1][i].first, list[참여자의 수 - 1][i].second, radius, paint)
}
3. canvas.drawBitmap() 이용하여 bitmap을 이미지로 그리기
drawBitmap()을 사용하기 위해서는 우선 bitmap 객체가 있어야 한다.
위에 AlbumProfileImg Overview에서 보여준 포카칩 이미지는 res/drawable 경로에 .png 파일로 저장되어 있다.
해당 리소스를 bitmap으로 치환하기 위해서 다음과 같은 코드를 사용한다.
//In AlbumThumbnailView Class
private var bitmap: Bitmap? = null
init {
bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.poka)
}
BitmapFactory의 decodeResource() 메서드의 인자는 다음과 같다.
- Resources 타입
- Int 타입의 리소스 id
drawBitmap() 메서드는 다음과 같은 argument를 갖는다.
- 첫번째 인자 : Bitmap 객체
- 두번째 인자 : src : Rect - 그려질 bitmap의 하위 부분을 의미하는데, 보통 null로 설정된다.
- 세번째 인자 : dst : RectF - bitmap이 scaled/Translated 되도록 하는 Rect객체 (=bitmap이 그려질 사각형)
- 네번째 인자 : bitmap의 style을 정해줄 Paint 객체
val rect = Rect(
0,
0,
width,
height
)
paint.color = Color.TRASNPARENT
canvas!!.drawBitmap(bitmap, null ,rect, paint)
Rect()를 이용해 Rect객체를 생성한 뒤 세번째 인자에 넣어준다.
기존 Bitmap 수정하기
카카오톡 프로필 이미지를 보면 이미지를 그대로 사용하는 것이 아니라 원 안에 이미지를 채워 넣어 사용하고 있다.
현재 포카칩 bitmap은 아무런 가공이 되지 않았기 때문에 리소스에서 가져온 그대로의 이미지를 갖고 있다.
Paint객체와 Canvas, Bitmap을 적절하게 이용하여 Bitmap을 가공할 예정이다.
1.Bitmap.확장함수 만들기
원하는 Bitmap을 가공하기 위해 확장함수를 만들도록 한다.
fun Bitmap.getCircleProfileImage(): Bitmap() {
val output = Bitmap.createBitmap(this.width, this.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
}
Bitmap.createBitmap() 사용하여 새로운 Bitmap을 만들어준 뒤, Canvas() 인자로 전달하여
현재 widget크기의 새로운 canvas를 만든다.
2.BlendingMode를 이용하여 Bitmap 이미지를 원 안으로 집어 넣기. (xfermode // PorterDuff.Mode)
canvas에 그려진 도형 및 bitmap을 blending하기 위해선 Paint 객체의 xfermode 값을 설정해야 한다.
(xfermode는 간단하게 설명하자면 일종의 그림 도구로써 canvas에 그려질 그림을 이미 그려진 그림과 섞을 수 있다.)
paint.xfermode의 값은 PorterDuffXfermode() 로 생성할 수 있다.
PorterDuffXfermode() 의 인자로 Int 타입의 FLAG가 전달되며 각 FLAG에 따라 그림이 Blending 되는 방식이 달라진다.
// Destination
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
// Source
PorterDuff.Mode.SRC_ATOP : 해당 FLAG는 source 그림은 destination 그림에 겹치는 부분을 제외한 남은 부분을 제거한다. 겹쳐진 source 그림 부분은 destination 그림보다 위에 위치한다.
FLAG에 대해 더 자세한 부분은 https://developer.android.com/reference/android/graphics/PorterDuff.Mode 에서
살펴볼 수 있다.
위의 방식을 이용하여 포카칩 bitmap 이미지가
원 모양의 기호 위에 그려질 수 있도록 (원을 벗어난 포카칩 그림 부분은 제거) 만들어 보자.
paint.color = Color.WHITE
//destination 이미지 그리기
canvas.drawCircle((this.width/2).toFloat(), (this.height/2).toFloat(), (this.width/2).toFloat(), paint)
//blending 적용
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
//source 이미지 그리기
val rect = Rect(0,0, this.width, this.height)
canvas.drawBitmap(this, null, rect, paint) //PorterDuffXferMode가 적용된 paint객체를 전달.
//paint blending 모드 null 설정
paint.xfermode = null
1. destination 이미지를 canvas.drawCircle()을 이용하여 원 기호를 만들어준다.
2. Paint객체에 xfermode를 설정해준다. (PorterDuff.Mode.SRC_ATOP)
3. source 이미지를 canvas.drawBitmap()과 xfermode가 적용된 Paint객체를 전달하여 그린다.
4. Paint객체의 xfermode를 null로 설정.
fun Bitmap.getCircleProfileImage(): Bitmap {
val output = Bitmap.createBitmap(this.width, this.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val rect = Rect(0, 0, this.width, this.height)
//canvas.drawARGB(0xFF, 0xFF, 0xFF, 0xFF)
//canvas.drawARGB(255, 139, 197, 186)
paint.color = Color.WHITE
canvas.drawCircle((this.width/2).toFloat(),(this.height/2).toFloat(), (this.width/2).toFloat(), paint)
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
paint.color = Color.BLUE
canvas.drawBitmap(this, rect, rect, paint)
paint.xfermode = null
return output
}
처음으로 만든 Bitmap타입의 output 변수를 canvas에 넣어 원하는 그림을 그렸다.
그려진 그림은 output에 모두 적용이 되었기에 (?) 해당 변수를 return한다.
AlbumProfileCustomView Class (최종)
지금까지 배운것을 총집합하여 이제부터 프로필 이미지 형태로 가공한 bitmap을
외부에서 읽어온 값(=participants)을 이용해 canvas의 여러 위치에 그려보도록 하자.
typealias CC = Pair<Float, Float>
class AlbumThumbnailView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val TAG = "AlbumThumbnailView"
private var radius = 0.0f
private var length = 0
private val thumbnailList = listOf(
listOf(CC(0.5F, 0.5F)),
listOf(CC(0.35F, 0.35F), CC(0.65F, 0.65F)),
listOf(CC(0.5F, 0.28F), CC(0.27F, 0.70F), CC(0.73F, 0.70F)),
listOf(CC(0.25F, 0.25F), CC(0.75F, 0.25F), CC(0.25F, 0.75F), CC(0.75F, 0.75F))
)
private val pointPosition: PointF = PointF(0.0f, 0.0f)
private var participants = 0
private var bitmap: Bitmap? = null
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
init {
context.withStyledAttributes(attrs, R.styleable.AlbumThumbnailView) {
participants = getInteger(R.styleable.AlbumThumbnailView_participants, 1) - 1
}
bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.poka)
}
private fun PointF.computeXYForThumbnail(cc: CC) {
x = width * cc.first
y = height * cc.second
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
length = (w / 2)
radius = (StrictMath.min(w, h) / 4).toFloat() * when (participants) {
0 -> 1.5f
1 -> 1.3f
2 -> 1.1f
else -> 1f
}
try {
bitmap = Bitmap.createScaledBitmap(bitmap!!, w, h, true)
} catch (error:Exception) {
}
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
Log.i(TAG, " Paint : ${paint}")
try {
paint.color = Color.WHITE
canvas!!.drawRect(
0.toFloat(),
0.toFloat(),
(width / 2 + length).toFloat(),
(width / 2 + length).toFloat(),
paint
)
paint.color = Color.TRANSPARENT
for (i in 0..participants) {
pointPosition.computeXYForThumbnail(thumbnailList[participants][i])
val rect = Rect(
(pointPosition.x - length/2 ).toInt(),
(pointPosition.y - length/2 ).toInt(),
(pointPosition.x + length/2 ).toInt(),
(pointPosition.y + length/2 ).toInt()
)
Log.i(TAG, "width: ${width}, height : ${height}")
canvas!!.drawBitmap(bitmap!!.getCircledBitmap(), null ,rect, paint)
}
} catch (error: Exception) {
}
}
fun Bitmap.getCircledBitmap(): Bitmap {
Log.i(TAG, "Bitmap.getCircledBitmap() = width: ${this.width}, height : ${this.height}")
val output = Bitmap.createBitmap(this.width, this.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val rect = Rect(0, 0, this.width, this.height)
paint.color = Color.WHITE
canvas.drawCircle((this.width/2).toFloat(),(this.height/2).toFloat(), (this.width/2).toFloat(), paint)
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
paint.color = Color.BLUE
canvas.drawBitmap(this, rect, rect, paint)
paint.xfermode = null
return output
}
}