코틀린 팀플 최종 - 알람 메모앱 만들기

커스텀 뷰 작성

songyooho 2024. 8. 23. 21:41
package com.example.alarmmemo.presentation.memo

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.net.Uri
import android.util.AttributeSet
import android.util.Log
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet.Constraint
import androidx.core.view.drawToBitmap
import com.example.alarmmemo.R
import com.example.alarmmemo.data.model.MemoModel
import com.example.alarmmemo.databinding.MemoBitmapMenuBinding

class MemoView(private val context: Context, attrs: AttributeSet): FrameLayout(context,attrs) {
    //세팅 프로퍼티
    private var eraseOn =false
    private val history = ArrayDeque<HistoryItem>()

    private var curX = 0f
    private var curY = 0f
    private var curIdx:Int? =null
    private var activatedIdx:Int?=null
    private var drawList = ArrayList<Path>()
    private var drawPaintList = ArrayList<Paint>()
    private var drawActivate = true
    private var curCase:Int = 0
    private val minHeight = dpToPx(context,50f)
    private val minWidth = dpToPx(context,50f)
    private var borderSize = 50f
    private val bitmaps = ArrayList<Bitmap>()
    private val bitmapRectFs = ArrayList<RectF>()
    private val bitmapPaint = Paint()
    private val borderPaint = Paint().apply {
        color = context.getColor(R.color.black)
        style = Paint.Style.STROKE
        strokeWidth = dpToPx(context,2f)
        pathEffect = DashPathEffect(floatArrayOf(10f,10f),0f)
    }
    private var drawPaint = Paint().apply {
        color = context.getColor(R.color.black)
        style = Paint.Style.STROKE
        strokeWidth = dpToPx(context,4f)
    }

    private val bitmapMenu by lazy {
        val inflater = LayoutInflater.from(context)
        MemoBitmapMenuBinding.inflate(inflater,this@MemoView,false).apply {
            bitmapMenuDelete.setOnClickListener {
                removeBitmap()
            }
            bitmapMenuCopy.setOnClickListener{
                addBitmap()
            }
            root.visibility =View.GONE
        }
    }

    init {
        addView(bitmapMenu.root)
    }

    fun addBitmap(uri: Uri){
        getBitmapFromUri(context,uri).getOrNull()?.let {
            bitmaps+=it
            bitmapRectFs+=RectF(0f,0f, dpToPx(context,100f),it.height.toFloat()*dpToPx(context,100f)/it.width.toFloat())
            history.addLast(HistoryItem(ActionType.AddBitmap,bitmaps.size-1,Pair(bitmaps.last(),bitmapRectFs.last())))
        }
        invalidate()
    }

    private fun addBitmap(){
        activatedIdx?.let {
            bitmaps+=Bitmap.createBitmap(bitmaps[it])
            bitmapRectFs+=RectF(bitmapRectFs[it])
            history.addLast(HistoryItem(ActionType.AddBitmap,bitmaps.size-1,Pair(bitmaps.last(),bitmapRectFs.last())))
        }
        invalidate()
    }

    fun addDraw(x:Float?,y:Float?){
        if(x==null||y==null) return
        drawList+=Path().apply { moveTo(x,y) }
        drawPaintList+=Paint().apply {
            color = context.getColor(R.color.black)
            style = Paint.Style.STROKE
            strokeWidth = dpToPx(context,2f)
        }
        history.addLast(HistoryItem(ActionType.AddDraw, drawList.size-1,Pair(drawList.last(),drawPaintList.last())))
    }

    fun removeBitmap(){
        activatedIdx?.let {
            history.addLast(HistoryItem(ActionType.EraseBitmap,it,Pair(bitmaps[it],bitmapRectFs[it])))
            bitmaps.removeAt(it)
            bitmapRectFs.removeAt(it)
            activatedIdx=null
            removeView(bitmapMenu.root)
        }
        invalidate()
    }

    //1~9로 9구획으로 나눔(왼위,위,오른위,왼,내부,오른,왼아래,아래,오른아래)
    fun checkCase(x:Float,y:Float):Pair<Int,Int>?{
        bitmapRectFs.forEachIndexed { idx,rect ->
            val hori = if(x in rect.left ..rect.left+borderSize){
                1
            }else if(x in rect.left+borderSize..rect.right-borderSize){
                2
            }else if(x in rect.right-borderSize..rect.right){
                3
            }else{
                4
            }
            val vert = if(y in rect.top..rect.top+borderSize){
                1
            }else if(y in rect.top+borderSize..rect.bottom-borderSize){
                2
            }else if(y in rect.bottom - borderSize..rect.bottom){
                3
            }else{
                4
            }

            val result = if(vert==1){
                when(hori){
                    1 -> 1
                    2 -> 2
                    3 -> 3
                    else -> 10
                }
            }else if(vert==2){
                when(hori){
                    1->4
                    2->5
                    3->6
                    else->10
                }
            }else if(vert==3){
                when(hori){
                    1->7
                    2->8
                    3->9
                    else->10
                }
            }else{
                10
            }
            if(result!=10){
                return Pair(idx,result)
            }
        }
        return null
    }

    private fun resize(offX:Float,offY:Float,x:Float?=null,y:Float?=null){
        curIdx?.let {
            Log.d("메모",curCase.toString())
            if(curCase==1||curCase==2||curCase==3){
                val new = bitmapRectFs[it].top + offY
                val newHeight = bitmapRectFs[it].height() - offY
                if(newHeight in minHeight..height.toFloat()){
                    bitmapRectFs[it].top = new
                }
            }
            if(curCase==7||curCase==8||curCase==9){
                val new = bitmapRectFs[it].bottom + offY
                val newHeight = bitmapRectFs[it].height() +offY
                if(newHeight in minHeight..height.toFloat()){
                    bitmapRectFs[it].bottom = new
                }
            }
            if(curCase==1||curCase==4||curCase==7){
                val new = bitmapRectFs[it].left + offX
                val newWidth = bitmapRectFs[it].width() - offX
                if(newWidth in minWidth..width.toFloat()){
                    bitmapRectFs[it].left = new
                }
            }
            if(curCase==3||curCase==6||curCase==9){
                val new = bitmapRectFs[it].right + offX
                val newWidth = bitmapRectFs[it].width() + offX
                if(newWidth in minWidth..width.toFloat()){
                    bitmapRectFs[it].right = new
                }
            }
        }
        invalidate()
        if(x!=null&&y!=null){
            curX=x
            curY=y
        }

    }

    private fun setActivate(x:Float?,y:Float?){
        if(x==null||y==null) return
        if(activatedIdx!=null&&!bitmapRectFs[activatedIdx!!].contains(x,y)){
            activatedIdx=null
        }
        bitmapRectFs.forEachIndexed { idx, rect ->
            if(rect.contains(x, y)){
                activatedIdx=idx
                return
            }
        }
        activatedIdx = null
    }

    override fun dispatchDraw(canvas: Canvas) {
        val list = ArrayList<Pair<MemoType,Any?>>()
        history.forEach {
            when(it.action){
                ActionType.EraseTextBox -> list.remove(Pair(MemoType.TextBox,it.data))
                ActionType.EraseDraw -> list.remove(Pair(MemoType.Draw,it.data))
                ActionType.EraseBitmap -> list.remove(Pair(MemoType.Bitmap,it.data))
                ActionType.EraseText -> list.remove(Pair(MemoType.Text,it.data))
                ActionType.AddTextBox -> list+=Pair(MemoType.TextBox,it.data)
                ActionType.AddDraw -> list+=Pair(MemoType.Draw,it.data)
                ActionType.AddBitmap -> list+=Pair(MemoType.Bitmap,it.data)
                ActionType.AddText -> list+=Pair(MemoType.Text,it.data)
            }
        }
        list.forEach {
            val (type,data) = it
            when(type){
                MemoType.TextBox -> null
                MemoType.Draw -> {
                    val (path,paint) = data as Pair<Path,Paint>
                    canvas.drawPath(path,paint)
                }
                MemoType.Bitmap -> {
                    val (bitmap,rectf) = data as Pair<Bitmap,RectF>
                    canvas.drawBitmap(bitmap,null,rectf,bitmapPaint)
                }
                MemoType.Text -> null
                MemoType.Default -> null
            }
        }
        Log.d("메모",list.toString())

        activatedIdx?.let {
            canvas.drawBitmap(bitmaps[it],null,bitmapRectFs[it],bitmapPaint)
            canvas.drawRect(bitmapRectFs[it],borderPaint)
        }
        super.dispatchDraw(canvas)
    }


    override fun onTouchEvent(event: MotionEvent?): Boolean {
        event?.let {
            when(it.action){
                MotionEvent.ACTION_DOWN -> {

                    bitmapMenu.root.visibility = View.GONE

                    curX = it.x
                    curY = it.y
                    setActivate(curX,curY)
                    if(activatedIdx!=null){
                        val selected = checkCase(curX,curY)
                        if(selected == null){
                            curIdx = null
                        }else{
                            curIdx = selected.first
                            curCase = selected.second
                        }
                    }

                    invalidate()

                    if(activatedIdx==null&&drawActivate){
                        addDraw(curX,curY)
                        return true
                    }

                    if(curIdx!=null) return true
                }
                MotionEvent.ACTION_MOVE ->{
                    Log.d("메모",drawActivate.toString()+","+activatedIdx.toString())
                    if(activatedIdx==null&&drawActivate){
                        drawList.last().lineTo(it.x,it.y)
                        invalidate()
                    }
                    val offX=it.x - curX
                    val offY=it.y - curY
                    resize(offX,offY,it.x,it.y)
                    if(curCase==5){
                        curIdx?.let { idx->
                            val maxX = minOf(bitmapRectFs[idx].right+offX,width.toFloat())
                            val minX = maxOf(bitmapRectFs[idx].left+offX,0f)
                            val maxY = minOf(bitmapRectFs[idx].bottom+offY,height.toFloat())
                            val minY = maxOf(bitmapRectFs[idx].top+offY,0f)

                            val adjLeft = if(offX>0) maxX - bitmapRectFs[idx].width() else minX
                            val adjTop = if(offY>0) maxY - bitmapRectFs[idx].height() else minY

                            bitmapRectFs[idx].offsetTo(adjLeft,adjTop)
                            invalidate()
                            curX = it.x
                            curY = it.y
                        }
                        invalidate()
                    }
                    return true
                }
                MotionEvent.ACTION_UP ->{
                    if(activatedIdx!=null){
                        bitmapMenu.root.x=(bitmapRectFs[activatedIdx!!].left+bitmapRectFs[activatedIdx!!].right)/2f - bitmapMenu.root.width/2f
                        bitmapMenu.root.y=bitmapRectFs[activatedIdx!!].top - dpToPx(context,50f)
                        bitmapMenu.root.visibility = View.VISIBLE
                        bitmapMenu.root.bringToFront()
                    }
                    curIdx = null
                    invalidate()
                    return true
                }
            }
        }


        return super.onTouchEvent(event)
    }
}

fun dpToPx(context: Context, dp:Float): Float{
    return TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        dp,
        context.resources.displayMetrics
    )
}

fun getBitmapFromUri(context: Context, uri: Uri):Result<Bitmap>{
    return runCatching {
        val inputStream = context.contentResolver.openInputStream(uri)
        val bitmap = BitmapFactory.decodeStream(inputStream)
        inputStream?.close()
        bitmap
    }
}

enum class MemoType{
    TextBox,
    Draw,
    Bitmap,
    Text,
    Default
}

enum class ActionType{
    EraseTextBox,
    EraseDraw,
    EraseBitmap,
    EraseText,
    AddTextBox,
    AddDraw,
    AddBitmap,
    AddText

}

data class HistoryItem(
    val action:ActionType,
    val zidx: Int,
    val data:Any?
)

-일차적으로 그림을 그리고 이미지를 추가,삭제, 복사,크기조절하는 기능을 넣었고 뒤로가기, 돌아가기 기능을 위한 history부분을 구현하였다.

-확장성을 고려하며 추가기능을 넣기위한 기반을 짜두었다.

 

@dispatchDraw에서 그려지는 부분

-뷰와 자식뷰(addView로 추가된 뷰도 포함)는

super.dispatchDraw(canvas)

이부분에 의해 그려진다. 그렇기 때문에 자식뷰를 그려지는 부분보다 위에 놓고 싶으면 이 코드를 아래로 밀어두면 된다.