관리 메뉴

Bull

[Flutter::Widget] 채팅 UI를 만들어 봅시다! 본문

Software Framework/Flutter

[Flutter::Widget] 채팅 UI를 만들어 봅시다!

Bull_ 2024. 7. 25. 19:29

서론

이번에 만들 앱을 기획하기전에 어떤 UX구조를 해야할 지 고민이 되서 먼처 채팅 UI의 기본적인 구조를 바탕으로 만들어 보았습니다. 채팅 UI는 만들다보면 간단해보일 거 같은 느낌이 드는 데 막상 해보면 조금 많이 어렵다고 느낄 수 있습니다. 왜냐하면 채팅의 히스토리를 어떻게 할 것인지 그 히스토리를 또 어떻게 저장할 것인지 많은 데이터가 담기기 때문에 생각할 게 많아지죠. 따라서 기본적인 틀 구성을 수학의 정석처럼 정석은 어떤지 한 번 살펴 보시다.

혼자 헛소리 조금만 하겠습니다 - (넘어가도 좋습니다)

제가 만들기 위한 앱은 앱을 들어왔을 때 바로 채팅을 칠 수 있는 구조로 만들어야하는데 BottomNavigationBar를 사용하면 아마 채팅을 치는데 불편함이 있겠죠? 그래서 BottomNavigationBar를 사용하지 않고 왼쪽 상단에 drawer를 통해 구현하는 게 좋겠습니다. 광고는 앱바 하단에 달아보죠.

CODE

GPT로 짠 CODE기 때문에 보고 코드를 리뷰하는 형태로 진행해보겠습니다.

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Chatbot UI Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: ChatbotScreen(),
    );
  }
}

class ChatbotScreen extends StatefulWidget {
  @override
  _ChatbotScreenState createState() => _ChatbotScreenState();
}

class _ChatbotScreenState extends State<ChatbotScreen> {
  final List<Map<String, String>> _messages = [];
  final TextEditingController _controller = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      setState(() {
        _messages.add({'sender': 'user', 'message': _controller.text});
        _messages.add({'sender': 'bot', 'message': '${_controller.text}'}); 
        _controller.clear();
      });
      _scrollToBottom();
    }
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Chatbot UI Demo'),
      ),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                final message = _messages[index];
                final isUser = message['sender'] == 'user';
                return ListTile(
                  title: Align(
                    alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
                    child: Container(
                      padding: EdgeInsets.all(10),
                      decoration: BoxDecoration(
                        color: isUser ? Colors.blueAccent : Colors.grey[300],
                        borderRadius: BorderRadius.circular(10),
                      ),
                      child: Text(
                        message['message'] ?? '',
                        style: TextStyle(color: isUser ? Colors.white : Colors.black),
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: InputDecoration(
                      hintText: '메시지를 입력하세요',
                      border: OutlineInputBorder(
                        borderRadius: BorderRadius.circular(10),
                      ),
                    ),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.send),
                  onPressed: _sendMessage,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

배울 만한 개념 (제 기준)

우선 제 기준으로 생소하다고 느낄 만한 내용들을 담아봤습니다. 객관적인 지표로 지식을 설명하기에 기본적인 것을 선별하기가 무척어렵네요.

 

[목록]

Align
ScrollController
WidgetsBinding.instance.addPostFrameCallback

 

 

(... CODE ...)
ListTile(
  title: Align(
    alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
    child: Container(
      padding: EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: isUser ? Colors.blueAccent : Colors.grey[300],
        borderRadius: BorderRadius.circular(10),
      ),
      child: Text(
        message['message'] ?? '',
        style: TextStyle(color: isUser ? Colors.white : Colors.black),
      ),
    ),
  ),
);
(... CODE ...)

Align class는 반환값을 Widget으로 간주합니다. 따라서 ListTiletitle로 Widget을 사용해야겠죠?(ListTile class)

말을 편하게 하기 위해 어떤 공간을 컨테이너라 하겠습니다. Align은 이제 aligment 프로퍼티를 통해 컨테이너 안에 배열을 원하는 위치에 배열할 수 있습니다.

https://api.flutter.dev/flutter/widgets/Align-class.html

isUser ? Alignment.centerRight : Alignment.centerLeft를 통해 만약 상대방이면 왼쪽 중앙에 두고 나의 채팅은 오른쪽 중앙으로 두는 작업을 진행합니다. 이제 이 컨테이너는 채팅을 칠수록 무한이 뻗어진 컨테이너로 나타낼 수 있습니다.

 

그 아래의 스타일 부분과 Text는 설명을 생략하게습니다.

ScrollController

ListView.builder(
  controller: _scrollController,
  itemCount: _messages.length,
  itemBuilder: (context, index) {
    ...
    return ListTile(
      ...
    );
  },
),

우리는 ListView.builder를 사용했습니다. 이 위젯은 리스트를 나타낼 때 사용하는데요. 여기서 세 프로퍼티 controller,itemCount,itemBuilder가 있는데 itemBuilderrequired를 요구하여 필수로 사용되는 부분입니다.

 

controller는 컨트롤러라고만 적혀있지만 공식 사이트를 보면 ScrollController class를 반환해야합니다.
itemCount는 몇개의 리스트를 만들고 싶은 지 개수를 적어야합니다.

 

itemBuilderNullableIndexedWidgetBuilder를 반환하는데 이는 Widget을 반환해주는 콜백함수입니다. NullableIndexedWidgetBuilder class는 주어진 인덱스에 대한 위젯을 예를 들어 목록에서 생성하지만 null을 반환할 수 있는 함수에 대한 사인입니다.

 

말이 어려우니 우리는 ListView.builderListTile을 반환해야하는 구나 정도로만 이해하고 넘어갑시다. 이 ScrollController를 통해 Expanded되어진 위젯을 스크롤할 수 있도록 만듭니다. 터치를 통해 밀어내면 이 컨트롤러를 통해 움직이는 것이 가능하지만 우리는 세세한 조정도 해야합니다. 이 다음에 나올 내용과 같습니다.

WidgetsBinding.instance.addPostFrameCallback

void _scrollToBottom() {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (_scrollController.hasClients) {
      _scrollController.animateTo(
        _scrollController.position.maxScrollExtent,
        duration: Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
    }
  });
}

_scrollToBottom는 스크롤을 컨트롤하기 위해 만든 메소드입니다. 예를들어 채팅이 계속 늘어나는데 이 부분이 없으면 채팅창은 채팅이 생성되더라도 새로 생긴 채팅에 대한 포커싱을 자동으로 하지 않습니다. 이를 구현하기 위해 사용되었습니다.

 

_scrollToBottom은 채팅을 추가할 때 실행하면 됩니다. 코드를 설명하겠지만 함수명과 같이 스크롤을 제일 하단으로 움직여주는 메소드입니다. 위의 다른 메소드인 _sendMessage의 코드를 확인해보면 메시지를 추가하고 _scrollToBottom를 실행하는 모습을 볼 수 있습니다.

 

이제 세세한 코드를 살펴보겠습니다.

 

WidgetsBinding:

WidgetsBinding은 Flutter 애플리케이션에서 위젯 계층과 Flutter 엔진을 연결하는 중요한 역할을 합니다. 위젯 계층은 우리가 작성하는 UI 코드이고, Flutter 엔진은 이 코드를 실제 화면에 렌더링하고 시스템과 상호작용합니다.

WidgetsBinding은 이 두 계층이 서로 소통할 수 있도록 돕는 접착제와 같은 역할을 합니다.

 

instance:
WidgetsBinding 클래스는 싱글톤으로 구현되어 있으며, instance는 이 클래스의 유일한 인스턴스에 접근하는 데 사용됩니다.

 

addPostFrameCallback:
addPostFrameCallback은 프레임이 렌더링된 후 호출될 콜백을 등록합니다. 이 콜백은 특정 작업이 프레임이 완료된 후 실행되도록 예약합니다.

 

WidgetsBinding.instance.addPostFrameCallback 이를 모아서 해석해보면 실제 렌더링 계층에서 작동하는 시스템에 인스턴스를 통해 접근하여 프레임이 렌더링 된 후 호출될 콜백을 등록하는 과정입니다.

 

_scrollController.hasClientscontroller: _scrollController, 프로퍼티와 같이 컨트롤러가 부착되어있는 지 확인합니다. hasClients property의 공식문서에 따르면 컨트롤러 부착없이 위치, 오프셋, 애니메이트 To 및 jumpTo와 같이 ScrollPosition 등을 호출하면 안됩니다.

 

이제 setState를 통해 상태를 업데이트 해볼텐데요. 부드러운 효과를 주기 위해 animateTo 애니메이션을 추가했습니다. _scrollController.position.maxScrollExtent를 통해 스크롤을 끝까지 내립니다. 그 아래의 프로퍼티 설명은 생략하겠습니다.