[Compose] Canvas로 인스타 문구를 완성시켜보자!

0

개발을 하다보니, 위와 같이 문구를 만들수 있는 기능이 필요했습니다.
간단히 보면 무척이나 쉬워보이지만, 개발로 들어가는것은 쉽지가 않았는데요.

    BasicTextField(
        value = "NHN FORWARD 2022 발표 \n 아자아자 화이팅!",
        onValueChange = {},
        Modifier.background(Color.White)
    )

단순하게 BackGround 함수를 호출하면, 가장 긴 LineWidth가 Width가 되어버리기 때문에 불가능합니다.

저희는 오늘 Compose Canvas를 통해 위와 같은 View를 만들고자 합니다.

Compose Canvas는 뭔가요?

내부적으로는 View 방식의 캔버스 객체를 사용합니다.
하지만 Canvas의 Helper함수 처럼 만들어졌기에, 혼란스러운 부분이 단순화됩니다.

그렇게 어려웠던 Canvas를 조금더 직관적이고 편하게 개발을 진행 할수 있습니다.(일단 Preview가 된다는점부터 차원이 달라집니다만..)

일단 Canvas를 사용하기전에 미리 해야할것을 정리해봅시다.

Canvas를 사용하기전에, 어떤 정보로 뭘그려야할지 정리하는게 중요합니다.
한번 같이 정리해볼까요?

  1. 글자수를 구하고, 네모칸을 만든다.
  2. 네모칸을 굴곡지게 만든다.

대충 요정도가 될 것 같습니다.

그렇다면 Canvas는 어디에서 사용하면 될까요?

TextField의 파라미터중, decorationBox가 존재합니다.

1. Decoration Box

Composable lambda that allows to add decorations around text field, such as icon, placeholder, helper messages or similar, and automatically increase the hit target area of the text field. To allow you to control the placement of the inner text field relative to your decorations, the text field implementation will pass in a framework-controlled composable parameter "innerTextField" to the decorationBox lambda you provide. You must call innerTextField exactly once.

아이콘, 자리 표시자, 도우미 메시지 등과 같은 텍스트 필드 주위에 장식을 추가하기위해 필요로하는 컴포저블 람다는 텍스트 필드에 다룰수 있는 영역을 증가 시킨다.
장식과 관련된 내부 텍스트 필드의 배치를 제어할 수 있도록 텍스트 필드 구현은 프레임워크 제어 구성 가능한 매개변수 "innerTextField"를 제공하는 장식 상자 람다에 전달합니다.
innerTextField는 정확히 한 번만 호출해야 합니다.

람다 함수로 들어오는 innerTextField는 아래 그자체인 컴포저블 함수입니다.

그래서 decorationBox를 사용하지않으면 기본값으로 @Composable { innerTextField -> innerTextField() }을 호출 합니다.
뭔가를 꾸미고자 한다면 이곳에 하기에 제격이겠죠!?

예를들어 Android: Hint를 구현하고자 한다면 decorationBox를 이용하면 쉽게 구현할 수 있습니다.

var value by remember { mutableStateOf(TextFieldValue("")) }
BasicTextField(
  value = value,
  onValueChange = { value = it },
  decorationBox = { innerTextField ->
      Row(
          Modifier
              .background(Color.LightGray, RoundedCornerShape(percent = 30))
              .padding(16.dp)
      ) {
          if (value.text.isEmpty()) {
              Text("Label")
          }
          innerTextField()  //<-- Add this
      }
  },
)

기존의 TextField의 형태(현재 파란색 테두리에 속한)는 유지한채로, 꾸미기가 가능합니다.

그렇다면 TextField에 대한 정보는 어디서 가져올수 있을까요?
onTextLayout을 통해 얻을수 있습니다.

2. onTextLayout

onTextLayout - Callback that is executed when a new text layout is calculated. A TextLayoutResult object that callback provides contains paragraph information, size of the text, baselines and other details. The callback can be used to add additional decoration or functionality to the text. For example, to draw a cursor or selection around the text.
새 텍스트 레이아웃이 계산될 때 실행되는 콜백입니다.
콜백이 제공하는 TextLayoutResult 개체에는 단락 정보, 텍스트 크기, 기준선 및 기타 세부 정보가 포함됩니다.
콜백을 사용하여 텍스트에 장식이나 기능을 추가할 수 있습니다.
예를 들어, 텍스트 주위에 커서나 선택 영역을 그리기위해 사용할수 있습니다.

onTextLayout에는 Text에 대한 많은 정보를 갖고있습니다.

아래 메서드는 Text가 공백인지 아닌지 확인하기위해 필요합니다.

  • lineCount : 현재 Text의 라인수를 구할 수 있습니다.
  • getLineStart(lineIndex) : 해당 라인의 첫시작을 가져옵니다.
  • getLineEnd(lineIndex) : 해당 라인의 끝을 가져옵니다.

아래 메서드는 좌표를 가져오기위해 필요합니다.

  • getLineLeft : Text라인의 왼쪽 좌표입니다.
  • getLineTop : Text라인의 위쪽 좌표입니다.
  • getLineRight : Text라인의 오른쪽 좌표입니다.
  • getLineBottom : Text라인의 아래쪽 좌표입니다.

이제 한번 프로그래밍을 시작해 봅시다!

1. 글자수를 구하고, 네모칸을 만든다.

BasicTextField(
    value = "NHN FORWARD 2022 발표 \n 아자아자 화이팅!",
    onValueChange = {},
  //주목
    onTextLayout = {
        textLayoutResult = it
    },
  //주목
    decorationBox = { innerTextField ->
        RoundBackground(textLayoutResult)
        innerTextField()
    }
)
  

TextField가 글자가 바뀔때마다, onTextLayout으로 callBack해서 들어옵니다.
textLayoutResult 에 대한 정보가 필요하기때문에 remember를 통해 상태를 저장합니다.

var textLayoutResult by remember {
	mutableStateOf<TextLayoutResult?>(null)
}

TextField의 상태를 알았다면, decorationBox에서 호출을 하면되겠죠?

fun RoundBackground(
  textLayoutResult: TextLayoutResult?
) {
  val density = LocalDensity.current
  textLayoutResult?.let { textLayoutResult ->
      Canvas(modifier = Modifier) {
//직접 만든 createBackGroundPath()
          drawPath(createBackGroundPath(textLayoutResult, density), Color.Green)
      }
  }
}

첫번째 메소드인 crateBackGroundPath()Path()를 반환하는 메소드이고, lineCount에 따라 createBackGroundRect()Rect를 합치는 역할을 하게 됩니다.

fun createBackGroundPath(textLayoutResult: TextLayoutResult, density: Density): Path {
    val backgroundShape = Path()

  //라인카운트에  반복한다.
    repeat(textLayoutResult.lineCount) { lineIndex ->
        if (checkLineNotEmpty(textLayoutResult, lineIndex)) {
  //사각형을 그리고, Path에 담기게된다.
            createBackgroundRect(textLayoutResult, lineIndex, density).also { rect ->
                backgroundShape.addRect(rect)
            }
        }
    }
    return backgroundShape
}


 /**
 * textLayoutResult의 좌표에 따라 사각형을 만드는 함수
 *
 * @param textLayoutResult
 * @param lineIndex
 * @param density
 * @return
 */
fun createBackgroundRect(textLayoutResult: TextLayoutResult, lineIndex: Int, density: Density): Rect {

    return Rect(
        textLayoutResult.getLineLeft(lineIndex),
        textLayoutResult.getLineTop(lineIndex),
        textLayoutResult.getLineRight(lineIndex),
        textLayoutResult.getLineBottom(lineIndex)
    )
}

/**
 * 시작점과 끝점을 보고, 같으면 백그라운드를 그리지 않는다.
 *
 * @param textLayoutResult
 * @param lineIndex
 * @return
 */
fun checkLineNotEmpty(textLayoutResult: TextLayoutResult, lineIndex: Int): Boolean {
    val lineStart = textLayoutResult.getLineStart(lineIndex)
    val lineEnd = textLayoutResult.getLineEnd(lineIndex)

    //라인이 같을경우
    if (lineStart == lineEnd) return false
    //라인의 마지막이 \n일 경우
    if (textLayoutResult.layoutInput.text[lineStart] == '\n' && lineEnd - lineStart == 1) return false

    return true
}


그래서 설정을 하게 되면, 글자 크기만큼 테두리가 생기게 됩니다.

2. 네모칸을 굴곡지게 만든다.

그려진 Path() 에 coner를 추가하려면, pathEffect를 사용해야 합니다.

하지만, 지금 Compose의 Canvas로는 pathEffect를 사용하기가 어렵습니다.

Style에 Fill이 아닌 Stroke로 지정해줘야하는데 그럴경우, 전체가 border에 색깔이 입혀지기 때문입니다.
Paint에서는 pathEffect를 전체로 줄수 있지만, ComposeCavas에서는 Paint를 지원하지 않습니다.

그렇다면, Native Canvas는 어떻게 호출할 수 있을까요?

  /**
 * Provides access to draw directly with the underlying [Canvas]. This is helpful for situations
 * to re-use alternative drawing logic in combination with [DrawScope]
 *
 * @param block Lambda callback to issue drawing commands on the provided [Canvas]
기본 Canvas 로 직접 그릴 수 있는 액세스를 제공합니다. 
이것은 DrawScope 와 함께 대체 드로잉 로직을 재사용하는 상황에 유용합니다.
매개변수: block - 제공된 Canvas 에서 그리기 명령을 실행하기 위한 Lambda 콜백
 */
inline fun DrawScope.drawIntoCanvas(block: (Canvas) -> Unit) = block(drawContext.canvas)

drawIntoCanvas를 통해 쉽게 호출이 가능합니다.

val paint = Paint().apply {
  color = Color.Black

  pathEffect = PathEffect.cornerPathEffect(radius = 5.dp.toPx())
}
drawIntoCanvas {
  it.drawPath(createBackGroundPath(textLayoutResult, density), paint)

}

이제 거의다 끝나가네요

3. Canvas에 Padding 넣기

패딩부터 넣어봅시다!

패딩은 상하 좌우를 줄수가 있는데 , Canvas 를 사용하기 떄문에 padding을 주는것은 불가능합니다.

어떤 방법을 사용해야할까요?

Canvas는 좌표값으로 위치를 계산합니다.

그렇기에 DP를 PX로 변환하여, 좌상단엔 패딩 값을 빼고, 우하단엔 패딩 값을 더하면 됩니다.

DP를 PX로 변환하는 방법은 with(LocalDensity.current) 를 통해 변환할 수 있습니다.

/**
 * textLayoutResult의 좌표에 따라 사각형을 만드는 함수
 *
 * @param textLayoutResult
 * @param lineIndex
 * @param density
 * @return
 */
fun createBackgroundRect(
    textLayoutResult: TextLayoutResult,
    lineIndex: Int,
    density: Density,
    paddingHorizontal: Dp,
    paddingVertical: Dp
): Rect {
    val paddingHorizontalPx = with(density){paddingHorizontal.toPx()}
    val paddingVerticalPx = with(density){paddingVertical.toPx()}
    return Rect(
  		//좌
        textLayoutResult.getLineLeft(lineIndex)- paddingHorizontalPx,
        //상
  		textLayoutResult.getLineTop(lineIndex) - paddingVerticalPx,
        //우
  		textLayoutResult.getLineRight(lineIndex) + paddingHorizontalPx,
        //하
  		textLayoutResult.getLineBottom(lineIndex) + paddingVerticalPx
    )
}  

이제 패딩값이 생겼습니다.

4. Painting에 Shadow 주기

마지막은 그림자를 입히는것 입니다.

아쉽게도 Compose Paint로는 Shadow를 입히는것이 불가능합니다.
아직까지 지원이 안되서 아쉽습니다만, NativePaint는 Shadow를 지원하기 때문에
NativePoint로 변환이 필요합니다.

Compose의 Paint는 asFrameworkPaint()를 통해 NativePaint로 변환이 가능합니다.

setShadowLayer()를 통해 그림자를 입혀봅시다.

public void setShadowLayer(float radius, float dx, float dy, int shadowColor) {
	throw new RuntimeException("Stub!");
}

fun createPaint(density: Density): Paint = Paint().apply {
    color = Color.Cyan
    

	asFrameworkPaint().setShadowLayer(
        5.dp.toPxf(density),
        10.dp.toPxf(density),
        5.dp.toPxf(density),
        android.graphics.Color.BLACK
    )

    pathEffect = PathEffect.cornerPathEffect(radius = 5.dp.toPxf(density))
}

조금더 Canvas와 친해졌다는 생각이 들었고, 계속 Canvas를 다뤄야겠다는 생각이 들었습니다.
이상입니다! 감사합니다~ <3

참고

Jetpack Compose Canvas

Compose에서 Canvas 시작하기

하드웨어 가속 - 구글 공홈

Android의 Canvas에 그려보자 : 선, 도형 그리고 그림까지!? - 찰스

profile
쉽게 가르칠수 있도록 노력하자

0개의 댓글