Flutter, 완성도 높은 채팅 기능을 만들기 위한 인터렉션 로직들

Ximya·2023년 5월 12일
4
post-thumbnail

채팅 UI를 구현할 때 고려해야 할 세세한 부분들이 많습니다. 우리는 하루에도 몇십 번씩 사용하는 채팅 앱을 만들기 위해서는 당연하다고 느껴질 수 있는 세부 사항들을 고려하고, 사용자 경험(UX)을 고려하여 고품질의 채팅 기능을 구현해야 합니다.

이 포스팅에서는 WhatsApp, 카카오톡, 그리고 라인과 같은 대표적인 채팅 앱에서 적용되는 UI 인터랙션 로직을 적용한 채팅 앱을 개발하는 방법에 대해 설명합니다.

기본적인 구조 (뼈대)

먼저, 채팅 스크린의 기본 구조를 살펴봅시다.

    Scaffold(
      appBar: AppBar(
        title: const Text("Chat"),
        backgroundColor: const Color(0xFF007AFF),
      ), // <-- 앱바
      body: Column(
        children: [
          Expanded(
            child: ListView.separated(...), // <- 채팅 리스트 뷰
          ), 
           _BottomInputField(), // <- 하단 고정 TextField 위젯
        ],
      ),
    );

일반적으로 채팅 스크린은 간단한 구조를 가지고 있습니다.
AppBar, Chat ListView, 그리고 하단에 고정된 TextField으로 구성되어 있습니다.

여기서 중요한 점은 채팅 리스트 뷰와 텍스트 필드를 Column 위젯으로 감싸야 하며, 채팅 리스트 뷰 섹션은 Expanded 위젯으로 감싸야 한다는 것입니다.

Column 위젯으로 감싸진 채팅 리스트 뷰입력창은 위아래로 구성이 된 상태에서,채팅 리스트 뷰 섹션이 Expand로 감싸졌기 때문에 자연스럽게 입력창 뷰가 하단에 고정이 됩니다. 굳이 Stack & Positioned 위젯을 이용해서 입력창 위젯을 하단에 고정시킬 필요가 없는 이점이 있습니다.

앞으로 계속 보여드릴 예제도 해당 구조로 레이아웃이 구성되어 있다는 점 유의해 주시면 좋겠습니다.


1. 가상 키보드의 영역을 감지하여 입력창과 채팅 리스트뷰 섹션이 변화에 대응하는 인터랙션

가장 먼저 고려해야 할 채팅 인터랙션은 가상 키보드가 나타났을 때 입력창채팅 리스트뷰 섹션의 변화에 대응하는 것입니다. 사용자 경험을 위해 가상 키보드가 나타났을 때 입력창과 채팅 리스트뷰가 자연스럽게 따라 움직이는 것이 중요합니다.

이를 위해 다음 두 가지 속성을 설정해야 합니다.

resizeToAvoidBottomInset 속성

    return Scaffold(
      resizeToAvoidBottomInset: true, // true값 할당
      appBar: AppBar(
        title: const Text("Ximya"),
        backgroundColor: const Color(0xFF007AFF),
      ),

먼저 Scaffold 위젯에 resizeToAvoidBottomInset 속성을 true로 설정해야 합니다. 이 속성이 true로 설정되면, 가상 키보드가 나타날 때 Scaffold 위젯이 자동으로 크기를 조정하여 가상 키보드와 겹치지 않도록 합니다.

reversed 속성

ListView.separated(
	reverse: true,
    itemCount: chatList.length,
    ...
 )     

두 번째로 ListView 위젯의 reversed 속성을 true로 설정해야 합니다. 이 속성은 리스트 아이템을 역순으로 배치하는지를 지정합니다. reversed를 true로 설정하면 아이템이 아래에서 위로 배치되고 가상 키보드의 크기 변화를 감지할 수 있게 됩니다.

NOTE : 인덱스와 위치
reversed: true로 설정하면 ListView의 아이템이 아래에서 위로 배치됩니다. 이에 따라 아이템의 인덱스와 화면상의 위치가 반대로 됩니다. 이를 고려하여 ListView에 전달되는 데이터를 조작해야되는 경우가 발생할 수 있습니다. 데이터의 조작이 필요한 경우 ListView의 데이터를 전달하기 전에 한번 더 값을 reversed 시키는게 정답이 될 수 있습니다.
ex) controller.chatList.reversed.toList()


2. 채팅이 추가될 때 아래로 스크롤 되는 인터렉션

채팅 리스트에 메시지가 추가될 때 해당 메시지가 가장 아래에 배치되고 자연스럽게 스크롤되어야 합니다. 이를 위해 ListView의 reversed 속성을 true로 설정해야 합니다. reversed를 true로 설정하면 아이템이 아래에서 위로 배치되므로 메시지가 추가될 때 ListView의 영역이 커지면서 스크롤 위치가 변경됩니다.

3.채팅 리스트 뷰 섹션에서 메세지가 위로 정렬이 되는 레이아웃

지금까지 listView 위젯의 reversed 속성을 true로 설정해야 한다고 말씀드렸습니다. 다만 이렇게 설정을 하면 채팅 리스트 섹션이 화면 가장 아래에 배치 된다는 문제가 발생합니다.

 Align(
	alignment: Alignment.topCenter,
	child: ListView.separated(
	shrinkWrap: true,
	reverse: true,
    itemCount: chatList.length,
    itemBuilder: (context, index) {
    return Bubble(chat: chatList[index]);
       },
    );
   ),

reversed 속성을 true로 설정하면 채팅 리스트 섹션이 화면 가장 아래에 배치되므로, 화면 상단에 채팅 메시지가 보이도록 하려면 몇 가지 수정이 필요합니다. ListView 위젯을 Align으로 감싸고 alignment 속성에 Alignment.topCenter 값을 전달하여 상단에 배치하도록 설정합니다. 또한 ListView에 shrinkWrap: true 속성을 설정해야 합니다. 이렇게 하면 ListView가 내부 콘텐츠에 맞게 크기를 조정하여 Alignment 위젯의 영향을 받아 상단에 배치됩니다.

4. 채팅 메세지 전송 시 스크롤 위치 최적화 인터렉션

채팅 메시지를 전송하는 순간에 현재 스크롤 위치가 어디에 있든 간에 가장 아래로 변경되어야 합니다. 이를 위해 ScrollController를 사용하여 ListView의 스크롤 동작을 제어할 수 있습니다.

final scrollController = ScrollController()

...

ListView.separated(
	shrinkWrap: true,
	reverse: true,
    controller: scrollController                                  
    itemCount: chatList.length,
    itemBuilder: (context, index) {
		return Bubble(chat: chatList[index]);
     },
 );            

먼저 변수에 ScrollController를 초기화시켜 줍니다. 그리고 ListView의 controller 속성에 해당 변수를 전달합니다. 이제 ListView의 스크롤 동작을 컨트롤러 설정할 수 있게 됩니다.

Future<void> onFieldSubmitted() async {
  addMessage();
   
  // 스크롤 위치를 맨 아래로 이동 시킴 
  scrollController.animateTo(
    0,
    duration: const Duration(milliseconds: 300),
    curve: Curves.easeInOut,
  );

  textEditingController.text = '';
}

그리고 채팅이 추가될 때 발생하는 메소드에 scrollController.animatedTo 이벤트를 적용하여 가장 아래로 스크롤 되는 애니메이션 동작을 추가합니다. animatedTo 메소드의 offset값을 0으로 전달한 이유는 listview.buidler가 reversed:true로 설정되어 있으므로, 0이라는 위치는 사실상 리스트의 맨 아래를 의미하기 때문입니다.

5. 채팅 영역을 클릭하면 가상 키보드를 사라지게 하는 인터렉션

자 이제 마지막입니다. 일반적인 채팅 앱에서는 가상 키보드가 올라온 상태에서 일반 채팅 리스트 영역을 탭 하면 가상 키보드 아래로 숨겨지는 인터렉션이 존재하는데요. 이 부분을 구현하기 위해서 간단하게 코드를 하나 추가하시면 됩니다.

          Expanded(
            child: GestureDetector(
              onTap: () {
                FocusScope.of(context).unfocus(); // <-- 가상 키보드 숨기기
              },
              child: Align(
                alignment: Alignment.topCenter,
                child: Selector<ChatController, List<Chat>>(
                  selector: (context, controller) =>
                      controller.chatList.reversed.toList(),
                  builder: (context, chatList, child) {
                    return ListView.separated(
                      shrinkWrap: true,
                      reverse: true,
                      padding: const EdgeInsets.only(top: 12, bottom: 20) +
                          const EdgeInsets.symmetric(horizontal: 12),
                      separatorBuilder: (_, __) => const SizedBox(
                        height: 12,
                      ),
                      controller:
                          context.read<ChatController>().scrollController,
                      itemCount: chatList.length,
                      itemBuilder: (context, index) {
                        return Bubble(chat: chatList[index]);
                      },
                    );
                  },
                ),
              ),
            ),
          ),

채팅 리스트 섹션을 GestureDetector 위젯으로 감싸고 onTap 함수로FocusScope.of(context).unfocus() 이벤트를 넘겨주면 됩니다.

// 1. 초기화
final focusNode = FocusNode();


// 2. focusNode 객체 전달
TextField(
	focusNode :  focusNode,
...
),


// 3. 채팅 섹션이 탭 되었을 때 
onChatListSectinoTapped() {
	focusNode.unfocus()

}

또 다른 방법으로는 FocusNode 객체를 사용하여 가상 키보드를 숨길 수도 있습니다. FocusNode 객체를 초기화하고 텍스트 필드에 focusNode 속성을 설정합니다. 그리고 채팅 리스트 섹션이 탭되었을 때 focusNode.unfocus()를 호출하여 가상 키보드를 숨깁니다.


마무리

이번 포스팅에서는 채팅 앱을 구성할 때 고려해야된 인터렉션들에 대해 알아보았습니다. 어떻게 보면 사소한 부분일 수 있지만 이런 인터렉션들을 고려했을 때 채팅 기능의 완성도가 월등히 높아진다고 생각합니다.
앞서 설명한 인터렉션뿐만 아니라 전체적인 구성이 궁금하시다면 예제 코드가 있는 깃헙 레포를 클론 받아보시길 바랍니다😀

profile
https://medium.com/@ximya

2개의 댓글

comment-user-thumbnail
2024년 3월 16일

감사합니다!!

1개의 답글