Jetpack Compose 를 사용하여 채팅 화면 UI 구현하기

이지훈·2023년 11월 15일
2
post-thumbnail
post-custom-banner

서두

다음과 같은 채팅 화면을 Jetpack Compose 로 구현하는데 있어서 문제가 되었던 부분들을 정리하고자 글을 작성해보도록 하겠다. 생각보다 아직 XML에 비해서 채팅 화면을 구현하는데에 있어 참고할만한 자료들이 보이지 않아, 상당히 애를 먹었고, 특히 키보드가 올라왔을 때에 대한 화면 대응(TopBar 는 사라지지 않고, 현재 위치 그대로 유지한채로, 채팅 목록과 TextField가 키보드에 가려지지 않도록 위로 올려주는 것)을 처리하는데 많은 시간이 소요되었다.

TL;DR

Window Insets API 와 imePadding() Modifier 를 활용하여 키보드 관련 문제를 해결할 수 있다.

우선 화면의 대략적인 전체 구성은 다음과 같다.

  Surface(
    modifier = modifier.fillMaxSize(),
    color = Gray50,
  ) {
 	// 전체 영역을 Column 으로 배치 
    Column(
      modifier.fillMaxSize()
    ) {
      // 채팅 화면의 TopBar
      ChatTopBar(
        onNavigateBack = onNavigateBack,
      )
      // 채팅 목록 
      LazyColumn(
      	// TopBar 영역과 TextField 영역을 제외한 나머지 공간을 모두 차지하도록
        modifier = Modifier.weight(1f),
        state = rememberLazyListState(),
      ) {
        items(
          items = chatMessageList,
          // 각 아이템의 적절한 고유 값
          key = { (it.key) },
        ) { chatMessage ->
           ChatBubble(chatMessage)
        }
      }
      // 채팅 입력을 위한 텍스트 필드와 전송 버튼
      Row(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
      ) {
        OutlinedTextField()
        IconButton(
          onClick = {
            if (chatInputMessage.isNotEmpty()) sendChatMessage()
          },
        ) {
          Icon(
            imageVector = Icons.AutoMirrored.Outlined.Send,
            contentDescription = stringResource(R.string.send_message_description),
          )
        }
      }
    }

이제 해당 화면을 만들기 위한 세부적인 사항들을 살펴보도록 하겠다.

문제 1

RecyclerView 에서 내가 보낸 채팅 아이템과 상대방이 보낸 채팅 아이템을 구분하여 좌우로 배치하는건 알겠는데 이를 LazyColumn 에서는 어떻게 구현 해야하는 것인가?

사실 LazyColumn에서 구현하는 것이 훨씬 간단하다. Jetpack Compose 의 장점이 List 를 구현하는데에 있어 그 코드량이 압도적으로 줄어든다는 것인데, RecyclerView 에서는 각 아이템의 대한 ViewHolder를 각각 만들어 주어 분기 처리하는 식의 방법을 사용했던 것 같다. 그러기 위해 BaseViewHolder 를 만들어 이를 상속하고...

문제 1 해결

하지만 LazyColumn 에서는

@Composable
fun ChatBubble(
  modifier: Modifier = Modifier,
  chatMessage: ChatMessageUiModel,
) {
  // 내가 보낸 채팅인지, 아닌지에 따라 Start 또는 End 정렬 
  val messageArrangement = if (chatMessage.isUser) Arrangement.End else Arrangement.Start

  // 채팅 아이템의 구성요소들(프로필 사진, 메세지, 시각)을 가로로 배치하기 위함
  Row(
    modifier = modifier
      .padding(8.dp)
      .wrapContentHeight()
      .fillMaxWidth(),
    horizontalArrangement = messageArrangement,
    verticalAlignment = Alignment.Bottom,
  ) {
  	// 내가 보낸 채팅 
    if (chatMessage.isUser) {
      TimeText(time = chatMessage.timestamp.formatTime())
      Spacer(modifier = Modifier.width(8.dp))
      MessageBox(
        message = chatMessage.message,
        isUser = true,
      )
    // 상대방이 보낸 채팅(프로필 사진을 포함)
    } else {
      ProfileImage(
        modifier = Modifier
          .align(Alignment.Top)
          .size(48.dp),
      )
      Spacer(modifier = Modifier.width(8.dp))
      MessageBox(
        message = chatMessage.message,
        isUser = false,
      )
      Spacer(modifier = Modifier.width(8.dp))
      TimeText(time = chatMessage.timestamp.formatTime())
    }
  }
}

@Composable
fun MessageBox(
  modifier: Modifier = Modifier,
  message: String,
  isUser: Boolean,
) {
  // optional 채팅이 길어질 경우 화면의 최대 2/3 를 차지 하도록
  val maxWidthDp = LocalConfiguration.current.screenWidthDp.dp * 2 / 3

  Box(
    modifier = modifier
      .widthIn(max = if (isUser) maxWidthDp else maxWidthDp - 56.dp)
      .clip(RoundedCornerShape(8.dp))
      .background(if (isUser) Gray200 else Gray300)
      .padding(8.dp),
    contentAlignment = Alignment.Center,
  ) {
    Text(
      text = message,
      color = Gray900,
      modifier = Modifier.padding(all = 4.dp),
      style = TextSRegular,
    )
  }
}

@Composable
fun TimeText(time: String) {
  Text(
    text = time,
    color = Gray500,
    style = InfoS,
  )
}

다음과 같이 복잡한 로직 없이 선언적 UI의 장점을 활용하여 간단하게 구현이 가능하다.

다만 스크롤 관련해선 조금 생각을 해야할 필요가 있는데, 채팅 화면의 경우 항상 가장 마지막으로 보낸, 가장 아래에 위치한 채팅이 보이도록 스크롤이 되어있는게 자연스럽기 때문에, 이를 위한 처리가 필요한데 이는

  LaunchedEffect(key1 = chatMessageList.size) {
    if (uiState.chatMessageList.isNotEmpty()) {
      listState.scrollToItem(uiState.chatMessageList.size - 1)
    }
  }

다음과 같이 LaunchedEffect 의 key를 전체 채팅 목록의 사이즈로 두어, 아이템의 갯수가 1개 이상일 경우 마지막 아이템의 위치로 스크롤 되도록 구현해 주었다.

아래 블로그 글의 방법도 추천한다.
LazyColumn에 item 이 추가될 때마다 마지막 item으로 스크롤하기

LanchedEffect 를 활용하는 방법을 사용할 경우 키보드가 올라왔을 때 제대로 채팅목록과 텍스트 필드가 밀려 올라가지 않는 문제가 발견하여 블로그 글을 참고하여 해결하였습니다. 추후 원인을 파악해서 수정하도록 하겠습니다.

문제 2

키보드가 화면을 가리지 않기 위해 windowSoftInputMode 를 adjustPan 으로 설정하면 TopBar 영역이 채팅목록에 의해 밀려 화면 위 밖으로 밀려나버리고, adjustResize 로 설정하면 TopBar 영역이 그대로 위치하지만 키보드가 채팅목록과 TextField 를 가리는 건에 대하여...

문제 2 해결

이를 해결하기 위해서는 Window insets API 에 대한 이해가 필요하다.

https://developer.android.com/jetpack/compose/layouts/insets?hl=ko

Android 플랫폼은 상태 표시줄 및 탐색 메뉴와 같은 시스템 UI를 그려야 합니다. 이 시스템 UI는 사용자가 사용 중인 앱과 관계없이 표시됩니다. WindowInsets는 앱이 올바른 영역에 그리고 시스템 UI가 UI를 가리지 않도록 시스템 UI에 관한 정보를 제공합니다.

기본적으로 앱의 UI는 상태 표시줄 및 탐색 메뉴와 같은 시스템 UI 내에 배치되도록 제한됩니다. 이렇게 하면 시스템 UI 요소가 앱 콘텐츠를 가리지 않습니다.

그러나 앱은 시스템 UI도 표시되는 이러한 영역에 표시하도록 선택하는 것이 좋습니다. 이렇게 하면 더 원활한 사용자 환경을 제공하고 앱에서 사용할 수 있는 창 공간을 최대한 활용할 수 있습니다. 또한, 특히 소프트웨어 키보드를 표시하거나 숨길 때 앱이 시스템 UI와 함께 애니메이션될 수 있습니다.

간단하게 요약하면 WindowInsets 를 통해 시스템 UI 에 대한 다양한 처리를 할 수 있다는 것인데, sytem bar, navigation bar, status bar 와 같은 영역으로 앱의 UI를 확장할 수 있도록 한다던지, 해당 영역을 침범하지 않도록 안전하게 막아주는 역할 등을 지원한다.

그리고 키보드 또한 시스템 UI 이므로 앱의 구성 요소들이 키보드(시스템 UI) 에 의해 가려지지 않도록 할 수 있는 함수와 애니메이션까지 기본적으로 지원해준다. 아주 아주 맘에 든다.

우선 이 Window Insets 을 이용하기 위해선 공식 문서에서 언급한 것 처럼 사전에 설정 해줘야 할 것 들이 있다.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    installSplashScreen()

	// 앱이 콘텐츠를 그리는 위치를 완전히 제어할 수 있도록 하기 위한 설정
    // 이 호출을 통해 앱이 시스템 UI 뒤에 표시되도록 요청
    WindowCompat.setDecorFitsSystemWindows(window, false)

    setContent {
      PsychatTheme {
        PsyChatApp()
      }
    }
  }
}
  1. windowSoftInputMode를 adjustResize 로 설정
<activity
      android:name=".MainActivity"
      android:exported="true"
      android:theme="@style/Theme.PsyChat.Splash"
      android:windowSoftInputMode="adjustResize">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  1. statusBar 영역과 naviationBar 영역의 색상을 투명하게 설정
<?xml version="1.0" encoding="utf-8"?>
<resources>

  <style name="Theme.Psychat" parent="android:Theme.Material.Light.NoActionBar">
    <item name="android:statusBarColor">@android:color/transparent</item>
    <item name="android:navigationBarColor">@android:color/transparent</item>
  </style>
</resources>

이제 사전 준비를 모두 완료 하였기 때문에

앱이 시스템 UI 뒤에 검은색 또는 단색을 그리거나 소프트웨어 키보드와 동기식으로 애니메이션을 적용하지 않을 수 있습니다.

위와 같은 문제는 발생하지 않을 것이다. 이제 Window Insets 이 제공하는 함수를 채팅 앱에 적용해보도록 하자.

// 채팅 입력을 위한 텍스트 필드와 전송 버튼이 포함된 Row
Row(
	modifier = Modifier
    	.fillMaxWidth()
        // IME(Input Method Editor) 즉, 키보드에 대한 padding() 적용
        .imePadding(),
    verticalAlignment = Alignment.CenterVertically,
   ) {
      OutlinedTextField()
      IconButton(
        onClick = {
          if (chatInputMessage.isNotEmpty()) sendChatMessage()
        },
      ) {
         Icon(
           imageVector = Icons.AutoMirrored.Outlined.Send,
           contentDescription = stringResource(R.string.send_message_description),
         )
      }
   }

IME가 닫히면 imePadding() 수정자는 IME에 높이가 없으므로 패딩을 적용하지 않습니다.

모든 구성요소가 하나의 Column 내에 배치되어 있으며, Column의 최상단에는 TopBar가 위치한다. LazyColumn 영역의 높이는 weight(1f) 로 지정되어 있었기 때문에, 텍스트 필드가 하단 패딩에 의해 위로 밀리면 LazyColumn 이 차지하는 영역(높이가) 줄어들게 된다.

이로써, 키보드가 올라올 때 키보드의 높이만큼 텍스트 필드를 위로 밀어 올려 그 위에 배치된 LazyColumn도 위로 밀리는, 하지만 TopBar는 그 위치를 유지하는 형태를 구현할 수 있게 되었다.

실행 영상

아직 해결되지 않은 문제는 위에 영상을 보면 알겠지만 퍼포먼스가 상당히 좋지 않은 것 같다. 키보드가 올라오고, 채팅 목록이 밀려올라가는 애니메이션의 진행속도가 매우 느려 버벅인다고 느껴질 정도이다. release 로 빌드할 경우 그나마 조금 나아지는 것 같긴 하다. 원인을 파악해서 해결 보도록 해야겠다.

해당 채팅 화면이 포함된 전체 코드는 아래 깃허브 링크에서 확인할 수 있습니다.
https://github.com/KU-LAST/psychat-android

내용 업데이트

Window Insets API 의 공식문서가 업데이트 되어서, 사전에 설정해주는 코드에 변경이 생겼다.

이전보다 간단해졌고, 함수의 네이밍도 좀 더 명확해진 것을 확인할 수 있었는데
위에 과정 중에서 3번은 삭제되었구 1번에서 MainActivity에 onCreate() 내부에서 선언해주는 함수를

enableEdgeToEdge()

해당 함수로 변경해주면 된다

참고 자료)

https://velog.io/@dldmswo1209/Compose-UI-LazyColumn%EC%97%90-item-%EC%9D%B4-%EC%B6%94%EA%B0%80%EB%90%A0-%EB%95%8C%EB%A7%88%EB%8B%A4-%EB%A7%88%EC%A7%80%EB%A7%89-item%EC%9C%BC%EB%A1%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4%ED%95%98%EA%B8%B0

https://developer.android.com/jetpack/compose/layouts/insets?hl=ko

https://www.youtube.com/watch?v=mlL6H-s0nF0

https://velog.io/@cksgodl/AndroidCompose-WindowInsets%EC%9D%84-Compose%EC%97%90%EC%84%9C-%EA%B4%80%EB%A6%AC%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95

https://stackoverflow.com/questions/68847559/how-can-i-detect-keyboard-opening-and-closing-in-jetpack-compose

https://stackoverflow.com/questions/76479701/android-jetpack-compose-cannot-change-basictextfield-cursor-thumb-color

profile
실력은 고통의 총합이다. Android Developer
post-custom-banner

5개의 댓글

comment-user-thumbnail
2023년 11월 24일

Using mutableshareflow for navigation may result in missed events

2개의 답글
comment-user-thumbnail
2024년 3월 15일

Compose로 채팅화면 구현하는 게 자료가 많이 없던데 도움이 많이 됐습니다! 감사합니다!

1개의 답글