코드로 우주평화

Flutter로 간단한 노트 앱을 만들어보자 ⑨ - 노트 목록 UI 리팩터링 본문

나는 이렇게 논다/Flutter 로 간단한 노트 앱을 만들어보자

Flutter로 간단한 노트 앱을 만들어보자 ⑨ - 노트 목록 UI 리팩터링

daco2020 2024. 11. 11. 23:53
반응형

이전 글에서 우리는 노트 목록 화면에 필요한 대부분의 기능을 구현했습니다.

Flutter로 간단한 노트 앱을 만들어보자 ⑦ - 노트 정렬 기능 구현하기

하지만 기능 구현에 초점을 맞추다 보니 사용성이나 UI는 크게 고려하지 않았는데요. 이번 글에서는 노트 목록 화면의 UI를 리팩터링 하여 사용성을 높이고 눈에도 보기 좋은 UI로 수정해 보겠습니다.

 

이번 글에서 수정할 UI의 결과는 다음과 같습니다. 기존 화면과 비교하여 보여드리겠습니다.

 

- 기존에는 하나의 노트 카드에 모든 정보가 담겨있어 어떤 내용이 중요한지 알기 어려웠습니다.

- 새롭게 수정한 화면에서는 노트 내용이 가장 상단에 그리고 수정 날짜만 하단에 표시합니다.

- 사용자가 노트 카드를 꾹 누르면 중요, 수정, 삭제 버튼이 표시되도록 했습니다.

- 추가로 삭제 버튼을 누를 경우 사용자의 실수를 예방하기 위해 삭제에 대한 안내 문구를 띄워줍니다.

 

 

주요 개발 내용

1. 중요, 수정, 삭제 버튼 리팩터링

2. 노트 삭제 안내 문구 모달 추가

3. 날짜 표현 유틸 함수 추가

4. 노트 카드 디자인 수정

5. 노트 내용에 줄 바꿈 허용

 

 

1. 중요, 수정, 삭제 버튼 리팩터링

우리 목표는 노트 카드를 길게 누르고 드래그 기능을 통해 아이콘을 눌러 동작시키는 것입니다.

 

이를 위해 가장 먼저 기존 StatelessWidget이었던 NoteCard 를 StatefulWidget으로 변경하고 필요한 상태 변수를 추가합니다.

class NoteCard extends StatefulWidget {
  ...
}

class _NoteCardState extends State<NoteCard> {
  bool _showActions = false;
  int? _activeIconIndex;
  ...
}

 

이렇게 NoteCard를 StatefulWidget으로 변경하면, 드래그 상태나 활성화된 아이콘 인덱스 등 위젯의 상태를 관리할 수 있습니다,

 

 

그다음으로 LongPressDraggable 위젯을 사용하여 드래그 기능을 추가합니다.

return LongPressDraggable<String>(
data: widget.note.id,
onDragStarted: () {
   setState(() {
      _showActions = true;
   });
},
onDragEnd: (details) {
   setState(() {
      _showActions = false;
      if (_activeIconIndex != null) {
      // 활성화된 아이콘에 따른 동작 실행
      _handleIconAction(_activeIconIndex!);
      }
      _activeIconIndex = null;
   });
},
onDragUpdate: (details) {
   // 드래그 위치에 따라 활성화될 아이콘 인덱스 계산
   final RenderBox box = context.findRenderObject() as RenderBox;
   final localPosition = box.globalToLocal(details.globalPosition);
   final iconIndex = _getActiveIconIndex(localPosition);

   if (_activeIconIndex != iconIndex) {
      setState(() {
      _activeIconIndex = iconIndex;
      });
   }
},
feedback: Container(), // 드래그 시 보여질 위젯 (빈 컨테이너로 설정)

 

 

data: 드래그하는 동안 전달될 데이터(note.id) 입니다.

onDragStarted: 드래그가 시작될 때 _showActions 상태를 true로 설정하여 액션 아이콘을 표시합니다.

onDragEnd: 드래그가 끝났을 때 _showActions를 false로 설정하고, 드래그가 끝난 위치에 활성화된 아이콘이 있는 경우 _handleIconAction 메서드를 호출해 관련 동작을 수행합니다.

onDragUpdate: 드래그 위치에 따라 활성화될 아이콘의 인덱스를 계산하여 _activeIconIndex로 설정. 이를 통해 사용자가 드래그 위치에 따라 특정 아이콘이 강조됩니다.

 

 

다음으로 아이콘을 표시할 수 있어야겠죠. 기존 ListTile 내의 trailing 값을 다음처럼 수정합니다.

trailing: _showActions
  ? Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildActionIcon(0, Icons.star_border, widget.note.label == NoteLabel.important ? Colors.yellow : Colors.grey[700]),
        _buildActionIcon(1, Icons.edit_outlined, Colors.blue),
        _buildActionIcon(2, Icons.delete_outline, Colors.red),
      ],
    )
  : null,

 

_showActions가 true일 때, 3개의 아이콘이 표시됩니다. 각각의 아이콘은 인덱스 순서에 따라 중요, 수정, 삭제를 나타냅니다.

 

 

이어서 아이콘을 생성하는 _buildActionIcon 함수를 만들어 보겠습니다.

Widget _buildActionIcon(int index, IconData icon, Color? color) {
  final isActive = _activeIconIndex == index;
  return Container(
    padding: EdgeInsets.symmetric(
      horizontal: 12.0,
      vertical: 8.0,
    ),
    decoration: isActive
        ? BoxDecoration(
            color: Colors.white.withOpacity(0.2),
            borderRadius: BorderRadius.circular(20),
          )
        : null,
    child: Icon(
      icon,
      color: color?.withOpacity(isActive ? 1.0 : 0.7),
      size: 28,
    ),
  );
}

 

_activeIconIndex와 index를 비교하여 특정 아이콘이 활성화되었음을 확인하고, 활성화가 되었다면 배경효과와 색상의 투명도를 조정하여 사용자에게 보여줍니다.

 

 

아이콘 위치를 계산하는 함수 _getActiveIconIndex 도 구현해 보겠습니다.

int? _getActiveIconIndex(Offset localPosition) {
  final iconWidth = 48.0; // 아이콘 버튼의 너비
  final spacing = 8.0; // 아이콘 사이 간격
  final totalWidth = (iconWidth * 3) + (spacing * 2); // 전체 너비
  final startX = context.size!.width - totalWidth;

  if (localPosition.dx < startX) return null;

  final relativeX = localPosition.dx - startX;
  final index = (relativeX / (iconWidth + spacing)).floor();

  return (index >= 0 && index < 3) ? index : null;
}

 

 

이 함수는 드래그 위치(localPosition)에 따라 활성화할 아이콘의 인덱스를 계산하는 함수입니다.

 

아이콘의 너비(iconWidth)와 간격(spacing)을 기반으로 드래그 위치가 어떤 아이콘 위에 있는지 확인하고, 위치가 아이콘 중 하나 위에 있으면 해당 인덱스를 반환하고, 그렇지 않으면 null을 반환합니다.

 

 

마지막으로 드래그가 끝났을 때 아이콘을 실행하는 _handleIconAction 함수를 구현해 보겠습니다. 

void _handleIconAction(int index) {
  switch (index) {
    case 0: // 중요 표시 토글
      final nextLabel = widget.note.label == NoteLabel.none ? NoteLabel.important : NoteLabel.none;
      widget.onLabelUpdate(widget.note.id, nextLabel);
      break;
    case 1: // 편집
      widget.onEdit(widget.note.id);
      break;
    case 2: // 삭제
      widget.onDelete(widget.note.id);
      break;
  }
}

 

인덱스의 값에 따라 위젯 내 콜백함수인 onLabelUpdate, onEdit, onDelete 을 호출하여 동작을 실행시킵니다.

 

 

여기까지 구현을 완료했다면 이제 아이콘 버튼은 평상시에 노출되지 않고 있다가 사용자가 해당하는 노트를 꾹 눌렀을 때에만 노출됩니다.

 

 

2. 노트 삭제 안내 문구 모달 추가

기존에는 삭제 아이콘(휴지통)을 누르면 바로 해당 노트가 삭제되었습니다. 저 또한 앞서 1번을 개발하면서 실수로 노트를 삭제하곤 했는데요. 이러한 사용자의 실수를 예방하고자 노트 삭제 시 확인 대화상자(AlertDialog)를 먼저 표시하도록 구현해 보겠습니다.

 

showDialog 함수를 만들고 AlertDialog이 나타나도록 구현하겠습니다.

showDialog(
  context: context,
  builder: (BuildContext context) {
    return AlertDialog(
      backgroundColor: ColorPalette.background,
      title: Text(
        '노트 삭제',
        style: TextStyle(color: ColorPalette.textWhite),
      ),
      content: Text(
        '정말 삭제하시겠습니까?',
        style: TextStyle(color: ColorPalette.textWhite),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: Text(
            '취소',
            style: TextStyle(color: Colors.grey),
          ),
        ),
        TextButton(
          onPressed: () {
            Navigator.of(context).pop();
            widget.onDelete(widget.note.id);
          },
          child: Text(
            '삭제',
            style: TextStyle(color: Colors.red),
          ),
        ),
      ],
    );
  },
);

 

AlertDialog는 타이틀, 본문 메시지, 두 개의 버튼(취소, 삭제)으로 구성합니다.

- 타이틀은 "노트 삭제"가 표시됩니다.

- 본문 메시지는 "정말 삭제하시겠습니까?"라는 메시지를 표시하여 사용자에게 안내 문구를 제공합니다.
- 취소 버튼은 대화상자를 닫기만 하고 아무런 작업도 수행하지 않도록 Navigator.of(context).pop()을 호출합니다.
- 삭제 버튼은 대화상자를 닫고 widget.onDelete(widget.note.id); 콜백을 호출하여 노트를 실제로 삭제합니다.

 

 

3. 날짜 표현 유틸 함수 추가

기존에는 카드 하단에 날짜가 '2024-11-09 23:59 52.552427' 처럼 불필요하게 많이 노출하고 있었습니다. 이를 사용자가 더 직관적으로 인지할 수 있도록 "몇 분 전 수정", "몇 시간 전 수정", "며칠 전 수정" 등으로 표시하도록 바꾸겠습니다.

 

먼저, date 형식을 바꿔주는 새로운 유틸 클래스를 만들어 보겠습니다.

 

date_formatter.dart 파일을 lib/utils/ 경로에 생성해 줍니다. 이 파일에서는 날짜를 가독성 있는 형식으로 변환하는 DateFormatter 클래스를 정의하겠습니다.

class DateFormatter {
  static String formatLastEdited(DateTime? dateTime) {
    if (dateTime == null) return "";

    final now = DateTime.now();
    final difference = now.difference(dateTime);

    if (difference.inDays > 0) {
      return '${difference.inDays}일 전 수정';
    } else if (difference.inHours > 0) {
      return '${difference.inHours}시간 전 수정';
    } else if (difference.inMinutes > 0) {
      return '${difference.inMinutes}분 전 수정';
    } else {
      return '방금 전 수정';
    }
  }
}

 

formatLastEdited 메서드는 DateTime.now()와 dateTime 간의 시간 차이(difference)를 계산하여 최근 수정 시간을 텍스트 형식으로 반환합니다.

 

 

유틸 클래스를 만들었다면 NoteCard에서 DateFormatter 를 적용하겠습니다. 기존 NoteCard의 subtitle에서 createdAt 속성의 텍스트 표시 방식을 DateFormatter formatLastEdited 메서드를 사용하도록 변경합니다.

subtitle: Text(
  DateFormatter.formatLastEdited(widget.note.createdAt),
  style: TextStyle(color: Colors.grey),
),

 

이제 NoteCard에서는 노트가 수정된 시간을 아래와 같이 텍스트로 보여줍니다. (예: "3시간 전 수정", "1일 전 수정", "방금 전 수정")

 

 

 

4. 노트 카드 디자인 수정

기존 노트 카드는 경계선도 없고 글자 수에 따라 크기가 달라졌기 때문에 시각적으로 정돈되어 보이지 않았습니다. 이러한 노트 카드를 수정하여 카드가 좀 더 보기 좋고 사용하기 편리하도록 디자인이 개선해 보겠습니다.

 

먼저 카드 레이아웃을 수정하겠습니다. 노트 카드의 바깥 레이아웃을 Container로 변경하고, 카드의 테두리와 배경색 추가, 둥근 모서리도 적용합니다.

child: Container(
  margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  decoration: BoxDecoration(
    color: ColorPalette.background,
    border: Border.all(color: ColorPalette.textWhite),
    borderRadius: BorderRadius.circular(16),
  ),
  constraints: BoxConstraints(
    minHeight: 120,
    maxHeight: 300,
  ),
  ...
),

 

margin을 통해 카드 간의 간격과 여백을 조정합니다.

decoration으로 테두리 색상과 둥근 모서리를 적용해 카드가 시각적으로 구분되도록 수정합니다.

constraints으로 카드의 최소 및 최대 높이를 제한하여 카드가 필요 이상으로 작아지거나 커지지 않도록 설정합니다.

 

 

BoxDecoration 안에 gradient 파라미터를 추가하여 LinearGradient 을 선언합니다. 

gradient: LinearGradient(
  begin: Alignment.topLeft,
  end: Alignment.bottomRight,
  colors: [
    ColorPalette.background,
    ColorPalette.background.withOpacity(0.95),
  ],
),

 

기존의 단색 배경 대신 LinearGradient를 이용해 내부 그라데이션 배경을 적용합니다. begin과 end를 설정하면 그라데이션이 카드의 왼쪽 상단에서 오른쪽 하단으로 자연스럽게 퍼지도록 설정할 수 있습니다.

 


BoxDecoration 안에 border 파라미터를 추가하여 Border.all 을 선언합니다.

border: Border.all(
  color: ColorPalette.textWhite.withOpacity(0.15),
  width: 0.5,
),

 

노트 카드의 경계선 색상도 일반 색상보다 투명한 흰색으로 설정하여 카드 경계선이 은은하게 느껴지도록 조정합니다.

 

 

카드의 내용 영역은 최대 줄 수를 12로 제한하고 텍스트가 넘칠 경우 ellipsis를 사용해 말줄임표로 표시합니다.

Text(
  widget.note.content,
  style: TextStyle(
    color: ColorPalette.textWhite,
    fontSize: 14,
  ),
  overflow: TextOverflow.ellipsis,
  maxLines: 12,
),

 

 

중요, 수정, 삭제 아이콘 버튼이 노트 내용을 시각적으로 방해하지 않도록, Positioned 위젯을 활용해 아이콘 그룹이 카드의 오른쪽 하단에 고정 배치합니다. 

if (_showActions)
  Positioned(
    right: 16,
    bottom: 8,
    child: Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildActionIcon(...),
        _buildActionIcon(...),
        _buildActionIcon(...),
      ],
    ),
  ),

 

 

앞서 수정했었던 날짜 표시 영역도 Positioned 위젯을 이용해 왼쪽 하단에 고정 배치합니다.

Positioned(
  left: 16,
  bottom: 16,
  child: Text(
    DateFormatter.formatLastEdited(widget.note.createdAt),
    style: TextStyle(color: Colors.grey),
  ),
),

 

 

boxShadow 를 이용하여 두 가지 그림자를 적용하여 입체감을 더할 수 있습니다.

boxShadow: [
  BoxShadow(
    color: Colors.black.withOpacity(0.2),
    blurRadius: 8,
    offset: Offset(0, 2),
  ),
  BoxShadow(
    color: ColorPalette.textWhite.withOpacity(0.05),
    blurRadius: 1,
    offset: Offset(0, 0),
  ),
],

 

blurRadius 옵션은 그림자가 퍼지는 범위를, offset 은 그림자와의 거리를 조절합니다.

 

여기까지 노트 카드 레이아웃과 디자인을 좀 더 보기 좋게 꾸며보았습니다. 위에 설명한 몇몇 변경은 사실 없어도 큰 차이가 없습니다만(특히 그라데이션) 추후에 비슷한 상황에서 사용할 때를 대비해 연습할 겸 적용해 보았습니다. 

 

여러분도 위 내용을 그대로 따라 하시기보다는 본인의 스타일에 맞게 수정을 해보시기 바랍니다.

 

 

 

5. 노트 내용에 줄 바꿈 허용

이제 마지막입니다!

 

기존에는 노트 쓰기를 통해 내용을 입력할 때, 줄 바꿈이 허용되지 않았습니다. 하지만 실제 노트를 작성할 때에는 문장 구조에 따라 줄바꿈이 필수적이죠. 그렇기 때문에 노트 쓰기 화면에서 줄 바꿈을 허용하고 노트 카드에서도 자동 줄 넘김과 행 간격을 조절해 가독성을 높여보겠습니다.

 

먼저, 노트 카드의 텍스트에 행 간격을 조정하고 softWrap 옵션을 추가해 자동으로 줄이 넘어가도록 설정합니다.

Text(
  widget.note.content,
  style: TextStyle(
    color: ColorPalette.textWhite,
    fontSize: 14,
    height: 1.5,  // 행 간격 추가
  ),
  softWrap: true,  // 텍스트가 자동으로 줄을 넘어갈 수 있도록 설정
  overflow: TextOverflow.ellipsis,
  maxLines: 12,
),

 

 

다음으로 note_detail_view.dart(노트 쓰기 화면) 파일로 이동하여 입력 텍스트 필드의 줄 수를 10줄로 제한합니다. 줄 수를 제한하는 이유는 사용자가 과도하게 많은 줄을 입력하는 것을 방지하기 위함입니다.

// lib/views/note_detail_view.dart

TextField(
  controller: _controller,
  maxLength: 300,
  maxLines: null,
  keyboardType: TextInputType.multiline,
  onChanged: (text) {
    int lineCount = '\n'.allMatches(text).length + 1;  // 줄 수 계산

    if (lineCount > 10) {  // 10줄 초과 시 제한
      final lines = text.split('\n');
      final limitedText = lines.take(10).join('\n');  // 상위 10줄만 유지

      _controller.text = limitedText;
      _controller.selection = TextSelection.fromPosition(
        TextPosition(offset: limitedText.length),
      );
    }

    setState(() {
      _charCount = _controller.text.length;
    });
  },
  style: TextStyle(color: ColorPalette.textWhite),
),

 

- maxLines: null 과 keyboardType: TextInputType.multiline 옵션을 통해 기본적으로 사용자가 줄 바꿈을 자유롭게 하도록 허용합니다. 

- lineCount로 줄 수를 계산하고, 줄 수가 10을 초과할 경우 상위 10줄만 남기고 나머지 부분은 자동으로 잘라내어 _controller.text에 반영합니다.
- 텍스트를 잘라냈다면 커서가 텍스트 끝에 유지되도록 TextSelection을 사용해 커서 위치를 업데이트합니다.
- 최종적으로 잘라낸 텍스트의 문자 수를 _charCount에 반영하여 UI에 정확한 문자 수가 표시되도록 합니다.

 


변경된 노트 UI 디자인을 영상으로 보겠습니다.

 

 

 

 

마무리

이번 글에서는 노트 목록 화면의 UI를 전반적으로 개선하여 사용자의 편의성과 가독성을 높이는 리팩터링 작업을 했습니다. 특히, 중요, 수정, 삭제 버튼을 숨기고 필요한 순간에만 표시되도록 인터랙션 요소를 적용했죠.

 

추가로, 날짜 형식을 사용자가 더 인지하기 쉽게 변환하고 노트 내용에 줄 바꿈을 허용해 노트 작성에 대한 사용성도 높였습니다. 

 

이번에 변경한 UI가 끝이 아닙니다. 앞으로 추가되는 기능과 사용자의 피드백 등을 통해 계속해서 다듬어나갈 예정입니다. 이러한 고민과 시행착오가 앱의 완성도를 높일 것이라 믿습니다.

다음 글에서는 노트 쓰기에 대한 로직과 화면을 리팩터링 해보겠습니다.

 

[Flutter로 간단한 노트 앱을 만들어보자] 시리즈는 직접 독학으로 하나씩 만들어나가는 과정이므로 틀리거나 부족한 내용이 있을 수 있습니다. 조언과 피드백을 댓글로 남겨주시면 적극 반영하겠습니다. 감사합니다.
반응형