목록 상자로 시작한 화면이 어느 날 "옆에 날짜도 보여 주고, 금액 순으로 정렬도 되게 해 주세요"라는 요구를 받으면 갈 곳은 하나다. 리스트 컨트롤, CListCtrl 의 리포트 보기다. 탐색기의 자세히 보기 화면이 바로 이 컨트롤이다. 기능이 많은 만큼 처음 붙일 때 순서를 헷갈리기 쉬워서, 자주 쓰는 흐름을 처음부터 끝까지 정리했다.
아래 코드는 대화 상자에 List Control 을 놓고 속성의 View 를 Report 로, 컨트롤 변수 m_list(CListCtrl)를 연결했다고 가정한다. 리소스 ID 는 IDC_LIST1 이다. View 를 Report 로 바꾸지 않으면 아래 코드를 다 넣어도 열이 보이지 않는다. 가장 흔한 실수다.
열 만들기와 확장 스타일
열은 InsertColumn 으로 만들고, 행 전체 선택이나 격자 같은 것은 확장 스타일로 켠다. 확장 스타일은 리소스 속성에 없는 것이 많아서 OnInitDialog 에서 코드로 켜는 것이 보통이다.
BOOL CMyDlg::OnInitDialog()
{
CDialogEx::OnInitDialog();
m_list.SetExtendedStyle(m_list.GetExtendedStyle()
| LVS_EX_FULLROWSELECT // 행 전체 선택 (기본은 첫 칸만 선택된다)
| LVS_EX_GRIDLINES // 격자
| LVS_EX_DOUBLEBUFFER); // 깜빡임 줄이기
m_list.InsertColumn(0, _T("이름"), LVCFMT_LEFT, 120);
m_list.InsertColumn(1, _T("날짜"), LVCFMT_CENTER, 100);
m_list.InsertColumn(2, _T("금액"), LVCFMT_RIGHT, 100);
return TRUE;
}
LVS_EX_FULLROWSELECT 를 빼면 첫 번째 칸을 눌러야만 행이 선택되고 나머지 칸을 눌러서는 선택되지 않는다. 표처럼 쓰는 화면이라면 거의 항상 켠다. 참고로 첫 번째 열(0번)은 정렬 형식을 오른쪽이나 가운데로 줘도 왼쪽 정렬로 그려진다. 금액처럼 오른쪽 정렬이 필요한 값은 첫 열에 두지 않는 것이 편하다.
행 넣기: InsertItem 다음에 SetItemText
행은 두 단계로 넣는다. InsertItem 으로 행을 만들면서 첫 칸을 채우고, 반환된 행 번호로 SetItemText 를 불러 나머지 칸을 채운다.
void CMyDlg::LoadRows(const CArray<Order>& orders)
{
m_list.SetRedraw(FALSE);
m_list.DeleteAllItems();
for (int i = 0; i < orders.GetCount(); i++)
{
const Order& o = orders[i];
int row = m_list.InsertItem(m_list.GetItemCount(), o.name); // 맨 뒤에 추가, 반환값이 실제 행 번호
m_list.SetItemText(row, 1, o.date.Format(_T("%Y-%m-%d")));
CString amount;
amount.Format(_T("%d"), o.amount);
m_list.SetItemText(row, 2, amount);
m_list.SetItemData(row, o.id); // 행마다 DB ID 를 붙여 둔다
}
m_list.SetRedraw(TRUE);
m_list.Invalidate();
}
SetItemText 의 행 번호를 반복문의 i 로 쓰지 않고 InsertItem 의 반환값으로 쓰는 이유는 정렬 스타일 때문이다. 속성의 Sort 가 켜져 있으면 행이 넣은 위치가 아닌 곳에 들어가므로, 반환값을 쓰지 않으면 다른 행의 칸을 채우게 된다. 행마다 ID 를 붙여 두는 SetItemData 는 아래 정렬과 선택에서 쓸모가 있다.
선택 행 읽기와 더블클릭
선택된 행은 위치(POSITION)로 하나씩 꺼낸다. 단일 선택이라도 같은 방식을 쓴다. 속성의 Single Selection 을 켜면 하나만 고를 수 있다.
POSITION pos = m_list.GetFirstSelectedItemPosition();
while (pos != nullptr)
{
int row = m_list.GetNextSelectedItem(pos);
int id = static_cast<int>(m_list.GetItemData(row));
CString name = m_list.GetItemText(row, 0);
// id, name 사용
}
행을 더블클릭했을 때 상세 화면을 띄우는 것은 NM_DBLCLK 알림으로 받는다. 알림 구조체의 iItem 이 더블클릭한 행이고, 빈 곳을 더블클릭하면 -1 이다.
BEGIN_MESSAGE_MAP(CMyDlg, CDialogEx)
ON_NOTIFY(NM_DBLCLK, IDC_LIST1, &CMyDlg::OnNMDblclkList1)
ON_NOTIFY(LVN_COLUMNCLICK, IDC_LIST1, &CMyDlg::OnLvnColumnclickList1)
END_MESSAGE_MAP()
void CMyDlg::OnNMDblclkList1(NMHDR* pNMHDR, LRESULT* pResult)
{
auto* item = reinterpret_cast<LPNMITEMACTIVATE>(pNMHDR);
if (item->iItem >= 0)
{
int id = static_cast<int>(m_list.GetItemData(item->iItem));
// 상세 화면 열기
}
*pResult = 0;
}
열 머리글 클릭으로 정렬하기
열 머리글을 누르면 LVN_COLUMNCLICK 알림이 오지만, 컨트롤이 알아서 정렬해 주지는 않는다. 비교 함수를 넘겨 SortItemsEx 를 부르면 된다. SortItems 와 달리 SortItemsEx 는 비교 함수에 행 번호를 넘겨주므로, 그 행의 칸 문자열을 바로 꺼내 비교할 수 있다.
struct SortParam { CListCtrl* list; int column; bool ascending; };
static int CALLBACK CompareRows(LPARAM row1, LPARAM row2, LPARAM param)
{
auto* p = reinterpret_cast<SortParam*>(param);
CString a = p->list->GetItemText(static_cast<int>(row1), p->column);
CString b = p->list->GetItemText(static_cast<int>(row2), p->column);
int result = (p->column == 2)
? (_ttoi(a) - _ttoi(b)) // 금액 열은 숫자로
: a.Compare(b); // 나머지는 문자열로
return p->ascending ? result : -result;
}
void CMyDlg::OnLvnColumnclickList1(NMHDR* pNMHDR, LRESULT* pResult)
{
auto* view = reinterpret_cast<LPNMLISTVIEW>(pNMHDR);
// 같은 열을 다시 누르면 방향을 바꾼다
m_sortAscending = (view->iSubItem == m_sortColumn) ? !m_sortAscending : true;
m_sortColumn = view->iSubItem;
SortParam param = { &m_list, m_sortColumn, m_sortAscending };
m_list.SortItemsEx(CompareRows, reinterpret_cast<LPARAM>(¶m));
*pResult = 0;
}
금액 열을 문자열로 비교하면 "100" 이 "20" 보다 앞에 온다. 숫자 열은 숫자로 바꿔 비교해야 한다. 날짜를 yyyy-MM-dd 형식으로 넣어 두면 문자열 비교로도 날짜 순서가 맞는다.
수십만 행은 가상 목록으로
위 방식은 행마다 컨트롤이 문자열을 복사해 들고 있다. 수천 행까지는 괜찮지만 수십만 행을 넣으면 넣는 데만 한참 걸리고 메모리도 많이 쓴다. 이럴 때는 가상 목록을 쓴다. 속성의 Owner Data(LVS_OWNERDATA)를 켜면 컨트롤은 데이터를 들고 있지 않고, 화면에 보일 칸이 생길 때마다 프로그램에 "이 칸에 뭘 그릴까"를 묻는다.
// 데이터는 프로그램이 들고 있고, 컨트롤에는 개수만 알려 준다
m_list.SetItemCountEx(static_cast<int>(m_orders.size()), LVSICF_NOSCROLL);
// ON_NOTIFY(LVN_GETDISPINFO, IDC_LIST1, &CMyDlg::OnLvnGetdispinfoList1)
void CMyDlg::OnLvnGetdispinfoList1(NMHDR* pNMHDR, LRESULT* pResult)
{
auto* info = reinterpret_cast<NMLVDISPINFO*>(pNMHDR);
LVITEM& item = info->item;
if (item.mask & LVIF_TEXT)
{
const Order& o = m_orders[item.iItem];
CString text;
switch (item.iSubItem)
{
case 0: text = o.name; break;
case 1: text = o.date.Format(_T("%Y-%m-%d")); break;
case 2: text.Format(_T("%d"), o.amount); break;
}
_tcsncpy_s(item.pszText, item.cchTextMax, text, _TRUNCATE);
}
*pResult = 0;
}
가상 목록에서는 InsertItem, SetItemText, SortItemsEx 를 쓰지 않는다. 정렬은 프로그램이 들고 있는 데이터(m_orders)를 직접 정렬한 뒤 Invalidate() 로 다시 그리게 하면 된다. 행 개수가 몇 개든 컨트롤이 묻는 것은 화면에 보이는 수십 칸뿐이라, 수십만 행도 스크롤이 가볍다.
목록 상자 쪽의 기본기는 MFC CListBox 자주 쓰는 것 정리에 따로 정리했다.