코드로 우주평화

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

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

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

daco2020 2024. 11. 9. 23:37
반응형

이번 글에서는 우리가 작성한 노트를 불러올 때 중요도, 생성일시, 수정일시, 나아가 노트 검색 시에는 정확도 순으로 가져올 수 있는 '정렬 기능'을 구현해 보겠습니다.

 

정렬 기능의 결과는 다음과 같습니다.

 

- 기본적으로는 생성일시 내림차순 입니다.

- 우측 상단 정렬 버튼을 통해 정렬 메뉴 팝업을 열 수 있습니다.

- 생성일시를 다시 한 번 클릭하면 오름차순으로 정렬됩니다.

- 네 번째 이미지는 중요도순으로 정렬한 모습입니다.

 

 

주요 개발 내용

1. 노트 정렬 기능 추가

2. 노트 목록 화면 코드 리팩터링

3. 검색어 입력 시 정확도 내림차순으로 정렬

4. 노트 목록 상태관리 분리

 

1. 노트 정렬 기능 추가

노트 목록을 정렬하기 위한 정렬 기능을 추가해 보겠습니다. 정렬 기준은 생성일, 수정일, 중요도 입니다. 이 기준들을 내림차순 혹은 오름차순으로 정렬할 수 있도록 해봅시다.

 

note_list_view.dart 파일을 아래 내용처럼 수정합니다.

 

NoteLabel에 priority 필드 추가

enum NoteLabel {
  important(priority: 2),
  none(priority: 1);

  final int priority;
  const NoteLabel({required this.priority});
}

 

NoteLabel Enum에 각 라벨의 우선순위를 나타내는 priority 필드를 추가했습니다. none은 priority: 1, important는 priority: 2로 설정하여, 중요도가 높은 항목이 상위에 표시될 수 있게 했습니다.

 

이후에 정렬 함수에서 이 priority 값을 참조하여 정렬 순서를 결정합니다. 예를 들어 important 는 none 보다 중요도가 높으므로 내림차순 정렬을 할 때, important 값이 위로 정렬됩니다.

 

정렬 기준 Enum SortCriteria 추가

enum SortCriteria {
  createdAt,
  updatedAt,
  importance,
}

 

노트를 어떤 기준으로 정렬할지 선택할 수 있도록 SortCriteria Enum을 추가합니다. createdAt, updatedAt, importance 세 가지 기준을 정의하여, 사용자가 원하는 정렬 기준을 설정할 수 있습니다.

 

 

_sortNotes 정렬 함수 구현

_sortNotes함수는 현재 선택된 정렬 기준을 기반으로 노트를 정렬하는 함수입니다. _currentSortCriteria와 _isAscending 값을 참조하여 정렬 순서를 결정합니다.

List<NoteModel> _sortNotes(List<NoteModel> notes) {
  switch (_currentSortCriteria) {
    case SortCriteria.importance:
      return sortList(notes, (note) => note.labelPriority, descending: !_isAscending);
    case SortCriteria.updatedAt:
      return sortList(notes, (note) => note.updatedAt, descending: !_isAscending);
    case SortCriteria.createdAt:
      return sortList(notes, (note) => note.createdAt, descending: !_isAscending);
  }
}

 

_currentSortCriteria와 _isAscending 값을 참조하기 위해 변수를 추가해 주세요.

class _NoteListViewState extends State<NoteListView> {

  final TextEditingController _searchController = TextEditingController();

  SortCriteria _currentSortCriteria = SortCriteria.createdAt;
  bool _isAscending = false;
  
  ...

 

 

정렬 메뉴 컴포넌트 _SortMenu 추가

정렬 옵션을 선택할 수 있는 팝업 메뉴로, 앱 상단 AppBar의 actions 속성에 추가합니다.

 

사용자는 메뉴에서 생성일순, 수정일순, 중요도순으로 정렬할 수 있으며, 동일 정렬 기준을 반복 선택할 때마다 오름차순/내림차순을 전환할 수 있습니다.

class _SortMenu extends StatelessWidget {
  final SortCriteria currentSortCriteria;
  final bool isAscending;
  final ValueChanged<SortCriteria> onSelected;

  const _SortMenu({
    required this.currentSortCriteria,
    required this.isAscending,
    required this.onSelected,
  });

  Widget _buildSortOrderText() {
    return Text(isAscending ? '내림차순으로 바꾸기' : '오름차순으로 바꾸기', style: TextStyle(fontSize: 10));
  }

  PopupMenuItem<SortCriteria> _buildMenuItem({
    required SortCriteria criteria,
    required IconData icon,
    required String text,
  }) {
    return PopupMenuItem(
      value: criteria,
      child: Container(
        width: 200,
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Row(
              children: [
                Icon(icon, color: currentSortCriteria == criteria ? ColorPalette.textWhite : Colors.grey),
                const SizedBox(width: 8),
                Text(text),
              ],
            ),
            if (currentSortCriteria == criteria) _buildSortOrderText(),
          ],
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return PopupMenuButton<SortCriteria>(
      icon: Icon(Icons.sort, color: ColorPalette.textWhite),
      onSelected: onSelected,
      offset: const Offset(0, 48),
      constraints: const BoxConstraints(maxWidth: 200),
      itemBuilder: (context) => [
        _buildMenuItem(criteria: SortCriteria.createdAt, icon: Icons.create, text: '생성일순'),
        _buildMenuItem(criteria: SortCriteria.updatedAt, icon: Icons.update, text: '수정일순'),
        _buildMenuItem(criteria: SortCriteria.importance, icon: Icons.star, text: '중요도순'),
      ],
    );
  }
}

 

저는 _SortMenu 컴포넌트를 note_list_view.dart 하단에 함께 선언했습니다. _SortMenu 컴포넌트를 components 폴더에 선언하지 않는 이유는 정렬 메뉴 컴포넌트가 이곳 노트 목록 화면에서만 사용할 가능성이 높기 때문입니다.

 

저는 공통적으로 사용할 가능성이 낮다면 사용처 근처에 코드를 두는 편입니다. 컴포넌트 이름 앞에 '_'를 붙인 이유도 프라이빗하게 사용하기 위함입니다.

 

추후 정렬 메뉴 컴포넌트를 다른 곳에서도 사용하게 된다면 리팩터링을 통해 components 로 옮기면 됩니다.

 

 

 

2. 노트 목록 화면 코드 리팩터링

개발을 하다 보니 코드가 많아지고 문제가 보이기 시작하더군요. 그래서 중간에 한 번 전반적으로 살펴보고 리팩터링을 해보았습니다.

 

공통으로 사용하는 상수 값 분리

기존에 검색 바 패딩 값과 유사도 기준 값이 사용처에서 직접 입력해 사용하고 있었습니다. 이를 유지보수하기 용이하도록 상수로 분리합니다.

class _NoteListViewState extends State<NoteListView> {
  static const double _similarityThreshold = 0.3;
  static const double _searchBarPadding = 16.0;
  static const double _searchBarVerticalPadding = 12.0;
  
  ...

 

 

검색 필터 로직 분리 및 최적화

기존에는 검색 바의 텍스트가 변경될 때, onChanged 안에 노트 필터링 로직이 적혀 있었습니다. 이 때문에 코드가 길어져 가독성이 좋지 않았죠.

 

기존 filteredNotes라는 변수를 _filterNotes 라는 프라이빗 함수로 분리하여 코드의 가독성 높여봅시다. 

List<NoteModel> _filterNotes(List<NoteModel> notes, String query) {
  if (query.isEmpty) return notes;

  final searchQuery = query.toLowerCase();
  final searchWords = searchQuery.split(' ');

  return notes.where((note) {
    final noteContent = note.content.toLowerCase();

    if (noteContent.contains(searchQuery)) return true;

    final noteWords = noteContent.split(' ');

    return searchWords.any((searchWord) {
      return noteWords.any((noteWord) {
        return StringSimilarity.calculateSimilarity(searchWord, noteWord) >= _similarityThreshold;
      });
    });
  }).toList();
}

 

 

 

3. 검색어 입력 시 정확도 내림차순으로 정렬

검색어를 입력했을 때에는 정렬을 어떻게 해야 할까요? 저는 검색어와 가장 일치하는 정확도 순으로 정렬을 해야 한다고 생각했습니다. 

 

이를 위해 정확도 점수 계산 로직과 정렬 메뉴에 정확도 순을 추가하고, 사용자가 검색 결과를 더 직관적으로 확인할 수 있도록 해봅시다.

 

정확도 순 정렬 옵션 추가

SortCriteria 열거형에 accuracy 옵션을 추가하여 정렬 메뉴에 정확도순이 노출되도록 합니다.

enum SortCriteria {
  importance,
  createdAt,
  updatedAt,
  accuracy, // 정확도순 정렬 추가
}

 

 

정확도 점수 계산 로직 구현

앞서 리팩터링 했던 _filterNotes 함수에서 검색어와 각 노트의 유사도 점수를 계산하고, 이 점수를 기준으로 필터링하는 방식으로 변경해 봅시다.

 

점수는 다음과 같이 계산합니다.

 

1. 전체 일치 확인 : 검색어가 노트 내용에 포함되어 있으면 유사도 점수를 1.0으로 설정해 가장 높은 유사도로 간주합니다.
2. 단어 단위 유사도 확인 : 각 단어별 유사도를 계산하고, 유사도가 가장 높은 단어쌍의 점수를 최종 유사도 점수로 반영합니다.

3. 필터링 : 유사도 점수가 임계값 이상인 노트만 리스트에 포함시킵니다. (임계값은 앞서 상수로 정의한 _similarityThreshold 값을 사용)

 

기존 _filterNotes 함수의 `return notes.where((note) { ... }` 부분을 아래처럼 scoredNotes 로 수정합니다.

var scoredNotes = notes
    .map((note) {
      final noteContent = note.content.toLowerCase();
      double similarity = 0.0;

      if (noteContent.contains(searchQuery)) {
        similarity = 1.0;
      } else {
        final noteWords = noteContent.split(' ');
        for (var searchWord in searchWords) {
          for (var noteWord in noteWords) {
            final wordSimilarity =
                StringSimilarity.calculateSimilarity(searchWord, noteWord);
            similarity = similarity < wordSimilarity ? wordSimilarity : similarity;
          }
        }
      }

      return (note: note, similarity: similarity);
    })
    .where((scored) => scored.similarity >= _similarityThreshold)
    .toList();

 

 

정확도순 정렬 조건 추가

정확도 순 정렬을 선택할 때는 항상 내림차순으로 정렬합니다. 노트를 검색할 때, 굳이 오름차순으로(가장 정확하지 않은 것부터) 보고 싶은 경우는 없을 거라 판단했습니다.

 

그렇기 때문에 정확도 순의 경우에만 오름차순/내림차순 토글을 비활성화합니다. 

// 정확도순 정렬일 때는 항상 정확도 높은 순(내림차순)으로 정렬
if (_currentSortCriteria == SortCriteria.accuracy) {
  scoredNotes.sort((a, b) => b.similarity.compareTo(a.similarity));
}

// 정렬 로직 처리 함수에서 정확도 순 정렬 조건 추가
if (_searchQuery.isNotEmpty &&
    _currentSortCriteria == SortCriteria.accuracy) {
  return notes;
}

 

 

정렬 메뉴 조건 추가

정확도 순 정렬은 검색어가 있을 때에만 유효합니다. 검색어가 없는 경우에는 정확도순 옵션을 비활성화하여 사용자의 혼란을 줄이도록 합니다.

@override
Widget build(BuildContext context) {
  return PopupMenuButton<SortCriteria>(
    ...
    itemBuilder: (context) => [
      ...
      // 검색어가 있을 때만 정확도순 메뉴 표시
      if (searchQuery.isNotEmpty)
        _buildMenuItem(
          criteria: SortCriteria.accuracy,
          icon: Icons.sort_by_alpha,
          text: '정확도순',
        ),
    ],
  );
}

 

 

아래 이미지는 검색어를 입력했을 때, 정확도순 정렬 메뉴가 표시되는 결과 화면 입니다.

 

 

 

4. 노트 목록 상태관리 분리

정렬 기능을 완성했지만 한 가지 아쉬운 점이 있었습니다. 사용자가 노트 목록 화면에서 정렬은 한 뒤에 다른 탭으로 이동했다가 다시 목록 탭으로 돌아오면 정렬은 초기화되어 있었습니다.

 

저는 다른 탭으로 이동하고 돌아와도 기존에 설정한 정렬 옵션이 그대로 남아있기를 바랐습니다. 정렬 옵션을 다시 설정해야 하는 수고를 덜고 싶었습니다.

 

이를 위해서는 노트 목록 상태를 분리하여 상태를 유지할 필요가 있었습니다. 마지막으로 정렬 상태를 유지하도록 구현해 보겠습니다.

 

 

NoteListState 클래스 추가

노트 목록 상태를 관리하는 NoteListState 클래스를 추가합니다. 파일 위치는 lib/states 로 정했습니다.

 

NoteListState는 검색어와 정렬 상태를 집중적으로 관리합니다.

// states/note_list_state.dart

class NoteListState extends ChangeNotifier {
  String _searchQuery = '';
  SortCriteria _currentSortCriteria = SortCriteria.createdAt;
  bool _isAscending = false;

  String get searchQuery => _searchQuery;
  SortCriteria get currentSortCriteria => _currentSortCriteria;
  bool get isAscending => _isAscending;

  void setSearchQuery(String query) {
    // 검색어가 빈 값에서 입력으로 바뀌면, 정확도순으로 설정
    if (_searchQuery.isEmpty && query.isNotEmpty) {
      _currentSortCriteria = SortCriteria.accuracy;
    } else if (query.isEmpty && _currentSortCriteria == SortCriteria.accuracy) {
      _currentSortCriteria = SortCriteria.createdAt;
    }
    _searchQuery = query;
    notifyListeners();
  }

  void changeSortCriteria(SortCriteria criteria) {
    if (_currentSortCriteria == criteria) {
      _isAscending = !_isAscending;
    } else {
      _currentSortCriteria = criteria;
      _isAscending = false;
    }
    notifyListeners();
  }

  void resetState() {
    _searchQuery = '';
    _currentSortCriteria = SortCriteria.createdAt;
    _isAscending = false;
    notifyListeners();
  }
}

 

첫 번째 setSearchQuery(String query) 는 검색어를 업데이트하는 메서드입니다.


query가 빈 문자열에서 입력 값으로 변경될 때 정렬 기준을 정확도순(SortCriteria.accuracy)으로 자동 변경하고, 반대로 검색어가 지워졌을 때 정확도순으로 설정된 상태를 생성일순(SortCriteria.createdAt)으로 초기화합니다.


검색어 변경 시 notifyListeners()를 호출하여 UI에 변경을 알립니다.

 

 

두 번째 changeSortCriteria(SortCriteria criteria) 는 정렬 기준을 변경하는 메서드입니다.


정렬 기준을 변경할 때, 이전에 선택한 기준과 같은 기준이 다시 선택되면 정렬 순서(_isAscending)를 토글(오름차순 ↔ 내림차순)합니다.


새로운 정렬 기준이 선택되면 isAscending을 false로 초기화하여 내림차순으로 설정합니다. 변경이 완료되면 notifyListeners()로 상태 업데이트를 알립니다.

 


세 번째 resetState() 는 검색어, 정렬 기준, 정렬 순서를 초기값으로 리셋하는 메서드입니다.

 

_searchQuery를 빈 문자열('')로 초기화하고, _currentSortCriteria를 SortCriteria.createdAt, _isAscending을 false로 설정합니다.


이 메서드는 새 노트 추가 시나 초기화가 필요할 때 호출할 수 있습니다. 마찬가지로 상태가 변경되었음을 알리기 위해 notifyListeners()를 호출합니다.

 

 

 

검색 및 정렬 상태를 NoteListState에게 위임

기존에는 NoteListView에서 검색어와 정렬 관련 상태를 직접 관리했지만, 이제는 NoteListState에 위임하도록 변경했습니다.

 

따라서 NoteListView에서 검색어와 정렬 상태를 사용하거나 변경할 때 NoteListState를 호출하도록 수정합니다.

// NoteListView에서 상태 사용 예시

final noteListState = context.watch<NoteListState>();
...
noteListState.setSearchQuery(value);

 

아래 주석을 표시한 것처럼 사용할 수 있습니다.

// views/note_list_view.dart

import 'package:ttingnote/states/note_list_state.dart';

class _NoteListViewState extends State<NoteListView> {
  @override
  void initState() {
    super.initState();
    final noteListState = Provider.of<NoteListState>(context, listen: false);
    _searchController.text = noteListState.searchQuery; // 검색어 복원
    Provider.of<NoteService>(context, listen: false).getNotes();
  }

  Widget build(BuildContext context) {
    final noteService = context.watch<NoteService>();
    final noteListState = context.watch<NoteListState>(); // NoteListState 참조

    return Scaffold(
      appBar: AppBar(
        actions: [
          _SortMenu(
            currentSortCriteria: noteListState.currentSortCriteria, // 정렬 기준
            isAscending: noteListState.isAscending, // 정렬 순서
            onSelected: noteListState.changeSortCriteria, // 정렬 기준 변경
            searchQuery: noteListState.searchQuery, // 검색어
          ),
        ],
      ),
      body: Column(
        children: [
          // 검색 필드
          TextField(
            controller: _searchController,
            onChanged: (value) => noteListState.setSearchQuery(value), // 검색어 설정
          ),
          Expanded(
            child: Builder(
              builder: (context) {
                if (noteService.notes.isEmpty) {
                  return Center(child: Text('저장된 노트가 없습니다.'));
                }

                // 필터 및 정렬된 노트 목록
                var filteredNotes = _filterNotes(
                  noteService.notes,
                  noteListState.searchQuery,
                  noteListState.currentSortCriteria,
                );

                var sortedNotes = _sortNotes(
                  filteredNotes,
                  noteListState.currentSortCriteria,
                  noteListState.isAscending,
                  noteListState.searchQuery,
                );

                return ListView.builder(
                  itemCount: sortedNotes.length,
                  itemBuilder: (context, index) => NoteCard(note: sortedNotes[index]),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

 

 

 

노트 추가 시 정렬 상태 초기화 처리

노트를 추가할 때에는 새로 작성한 노트가 맨 위에 보이는 것이 사용자에게 자연스럽게 때문에 기본 정렬 기준(생성일시 내림차순)으로 초기화합니다. 

 

NoteDetailView에서 노트가 추가되면 NoteListState의 상태를 앞서 구현한 resetState() 메서드를 통해 초기화합니다.

// note_detail_view.dart

  Future<void> _saveNote() async {
    if (_controller.text.isNotEmpty) {
      final noteService = Provider.of<NoteService>(context, listen: false);

      if (isEdit) {
        await noteService.updateNote(widget.noteId!, content: _controller.text);
        await Provider.of<NoteService>(context, listen: false).getNotes();
        context.pop();
      } else {
        await noteService.addNote(_controller.text);
        Provider.of<NoteListState>(context, listen: false).resetState(); // 추가된 부분
        homeScreenKey.currentState?.onTabTapped(TabItem.noteList);
      }
    }
  }

 

 

main.dart 에서 NoteListState Provider 등록

NoteListState를 NoteService 와 마찬가지로 Provider로 등록하여, NoteListState의 상태를 전체 앱에서 사용할 수 있도록 설정합니다. 

// main.dart

import 'states/note_list_state.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<NoteService>(create: (_) => NoteService()),
        ChangeNotifierProvider(create: (_) => NoteListState()), // NoteListState Provider 추가
      ],
      child: MaterialApp.router(
        routerConfig: _router,
        ...
      ),
    );
  }
}

 

 

NoteService의 _notes 를 _originalNotes로 변경하고 복사본 반환

마지막으로 NoteService 에서 사용하던 _notes 를 _originalNotes 라는 이름으로 변경하고, notes getter는 항상 원본 데이터의 복사본을 제공하도록 변경합니다.

 

이렇게 원본 데이터를 유지하는 이유는, 화면을 전환하고 정렬 순서를 바꾸는 과정에서 데이터 배열이 꼬일 수 있기 때문입니다. 원본 데이터를 기억하고 매번 동일한 복사본을 반환함으로써 항상 동일한 정렬 결과를 얻을 수 있습니다.

class NoteService extends ChangeNotifier {
  final NoteRepository _noteRepository = NoteRepository();
  List<NoteModel> _originalNotes = [];  // 원본 데이터
  List<NoteModel> get notes => List.from(_originalNotes);  // 복사본 반환

  Future<void> getNotes() async {
    _originalNotes = await _noteRepository.fetchNotes();
    notifyListeners();
  }

  Future<void> addNote() async {
    // 원본 데이터 수정
    ...
    _originalNotes.add(note);
    notifyListeners();
  }

  Future<void> updateNote(String id,
      {String? content, NoteLabel? label}) async {
    final noteIndex = _originalNotes.indexWhere((note) => note.id == id);
    if (noteIndex == -1) return;

    final note = _originalNotes[noteIndex];
    final updatedNote = note.copyWith(
      content: content ?? note.content,
      label: label ?? note.label,
      updatedAt: DateTime.now(),
    );

    await _noteRepository.updateNote(updatedNote);
    _originalNotes[noteIndex] = updatedNote;
    notifyListeners();
  }

  Future<void> deleteNoteById(String id) async {
    // 원본 데이터 수정
    ...
    _originalNotes.removeWhere((note) => note.id == id);
    notifyListeners();
  }
}

 

이렇게 바꾸면 NoteService는 note 배열에 대해 단일 책임을 갖게 되고, 외부에서는 항상 보호된 복사본에만 접근하게 되어 안정적인 데이터를 제공할 수 있습니다.

 

 

노트 정렬 기능의 최종 결과를 영상으로 보겠습니다.

 

 

 

 

 

마무리

이번 글에서는 노트 앱에 정렬 기능과 상태 관리를 추가하였습니다. 이제 사용자는 우리가 구현한 정렬 기능을 통해 원하는 노트를 더 쉽게 찾을 수 있게 되었습니다.

 

우리는 이 과정에서 팝업 메뉴를 띄우고 상태관리를 분리하고 여러 크고 작은 리팩터링까지 해보았습니다. 정렬 기능이 생각보다 변경점도 많고 새롭게 알게 된 지식들도 많아서 글도 길어졌습니다.

 

글이 너무 길어지는 것을 우려하여 몇 가지 자잘한 변경사항은 이 글에 남기지 않고 넘어갔습니다. 이 글을 그대로 따라 하시기보다는 흐름만 참고하시어 코드의 변화 과정을 직접 확인해 보시기를 바랍니다.

 

 

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

 

반응형