기획 단계에서는 크게 신경쓰지 않았는데 막상 구현해보니 굉장히 어려워서 시행착오를 많이 겪은 파트였다.
인스펙터와 스크립트를 넘나들며 객체를 제어하는 유니티 방식에 익숙하지 않아서 이런저런 삽질을 많이 했다.
처음엔 각 Toggle 오브젝트마다 스크립트를 붙이고 거기서 CharacterListController가 들고 있는 데이터 리스트를 참조하여 정렬 혹은 필터링을 하도록 코드를 짰다가, Toggle 오브젝트는 순수한 UI 역할만 수행하도록 해야겠다는 생각이 들어 컨트롤러가 Toggle 오브젝트들을 참조하도록 구조를 바꾸고 정렬 및 필터링 기능은 전부 컨트롤러 스크립트에서 수행하도록 했다.
또한 캐릭터 필터 팝업창에 위치한 Toggle들은 제어 주체를 분리하는 것이 좋을 것 같아 별도의 컨트롤러인 FilterPopupController가 참조하도록 했고, 이 FilterPopupController를 다시 CharacterListController가 참조하는 구조로 만들었다.
결과적으로 유저가 토글을 클릭하면 OnValueChanged 이벤트가 발동되어 컨트롤러의 정렬 혹은 필터링 기능을 호출하고, 컨트롤러는 참조 중인 토글들의 isOn 값을 기준으로 정렬 및 필터링 기능을 수행하는 구조로 완성되었다.
public class CharacterListController : MonoBehaviour
{
private HttpClient client = new HttpClient();
private List<Character> sortedList;
// 캐릭터 목록 화면에 있는 토글들 참조
public Toggle sortByLevelToggle;
public Toggle sortByStarToggle;
public Toggle sortByPowerToggle;
public Toggle sortByGetDateToggle;
public Toggle filterByFavoritedToggle;
public Toggle filterByOwnedToggle;
// 필터 팝업창 오브젝트 참조 (필터 팝업창에 있는 토글들을 참조하기 위해)
public GameObject filterPopup;
void Start()
{
// 참조 중인 토글의 isOn 값이 변경되는 것을 듣는 리스너 설치
sortByLevelToggle.onValueChanged.AddListener(SortByLevel);
sortByStarToggle.onValueChanged.AddListener(SortByStar);
sortByPowerToggle.onValueChanged.AddListener(SortByPower);
sortByGetDateToggle.onValueChanged.AddListener(SortByGetDate);
filterByFavoritedToggle.onValueChanged.AddListener(OnFilterChanged);
filterByOwnedToggle.onValueChanged.AddListener(OnFilterChanged);
// 원본 데이터 가져오기
List<Character> characterList = client.FetchCharacters();
// 원본 데이터를 최초 정렬 기준으로 정렬
sortedList = InitialSort(characterList);
// 정렬된 데이터를 최초 필터링 기준으로 필터링
List<Character> filteredList = ApplyFilter(sortedList);
}
// 캐릭터 목록 화면의 필터 토글의 isOn값 변동시, 그리고 캐릭터 필터 팝업의 확인 버튼 클릭시 발동
public void OnFilterChanged(bool isOn)
{
// 정렬된 데이터를 변경된 필터링 기준으로 필터링
List<Character> filteredList = ApplyFilter(sortedList);
}
}
정렬 기능은 정렬 기준별로 오름차순/내림차순 두 가지를 구현했다. 정렬 전 상태를 보존할 필요가 없었기에 정렬된 리스트가 원본 리스트를 덮어쓰기하도록 했으며, 기능이 호출될 때마다 정렬 기준 하나씩만 적용해서 정렬이 이루어진다.
// 앱이 처음 켜질 때만 사용하는 최초 정렬 기능
// 최초 정렬 규칙: 내림차순으로 획득일, 전투력, 성급, 레벨 순서로 정렬
private List<Character> InitialSort(List<Character> characterList)
{
IEnumerable<Character> query = characterList;
query = query.OrderByDescending(item => item.GetDate)
.OrderByDescending(item =>
Utils.CalculatePower(item.MaxHp, item.Damage, item.Armor)
).OrderByDescending(item => item.NumOfStar)
.OrderByDescending(item => item.Level);
return query.ToList();
}
// 정렬 토글 isOn 값 변동시 호출되는 메서드들.
// 각 토글마다 다른 메서드를 호출함
private void SortByLevel(bool inDescending)
{
Debug.Log("isOn: " + inDescending);
if (inDescending)
{
sortedList =
sortedList.OrderByDescending(item => item.Level).ToList();
}
else
{
sortedList =
sortedList.OrderBy(item => item.Level).ToList();
}
List<Character> filteredList = ApplyFilter(sortedList);
Populate(filteredList);
}
private void SortByStar(bool inDescending)
{
Debug.Log("isOn: " + inDescending);
if (inDescending)
{
sortedList =
sortedList.OrderByDescending(item => item.NumOfStar).ToList();
}
else
{
sortedList =
sortedList.OrderBy(item => item.NumOfStar).ToList();
}
List<Character> filteredList = ApplyFilter(sortedList);
Populate(filteredList);
}
private void SortByPower(bool inDescending)
{
Debug.Log("isOn: " + inDescending);
if (inDescending)
{
sortedList = sortedList.OrderByDescending(item =>
Utils.CalculatePower(item.MaxHp, item.Damage, item.Armor)
).ToList();
}
else
{
sortedList = sortedList.OrderBy(item =>
Utils.CalculatePower(item.MaxHp, item.Damage, item.Armor)
).ToList();
}
List<Character> filteredList = ApplyFilter(sortedList);
Populate(filteredList);
}
private void SortByGetDate(bool inDescending)
{
Debug.Log("isOn: " + inDescending);
if (inDescending)
{
sortedList =
sortedList.OrderByDescending(item => item.GetDate).ToList();
}
else
{
sortedList =
sortedList.OrderBy(item => item.GetDate).ToList();
}
List<Character> filteredList = ApplyFilter(sortedList);
Populate(filteredList);
}
필터 기능은 필터링 기준별로 적용/해제가 가능하도록 구현했다. 정렬과 달리 On/Off 방식인 만큼 필터링 이전 상태를 보존할 필요가 있어서, 원본 리스트에 덮어쓰기하지 않고 필터링된 리스트를 따로 유지한다. 또한 매번 기능이 호출될 때마다 모든 필터의 적용 여부를 전부 체크하여 필터링을 한다.
public List<Character> ApplyFilter(List<Character> sortedList)
{
IEnumerable<Character> query = sortedList;
// 1. 캐릭터목록 화면에 있는 필터
// 필터 토글의 isOn 값이 true라면 필터 적용
if (filterByFavoritedToggle.isOn)
query = query.Where(item => item.IsFavorited);
if (filterByOwnedToggle.isOn)
query = query.Where(item => item.IsOwned);
// 팝업에 있는 토글들의 isOn 값을 담은 배열 참조
bool[] filtersFromPopup =
filterPopup.GetComponent<FilterPopupController>().ToggleValues;
// 2. 팝업에 있는 필터
// 필터 토글의 isOn 값이 false라면 차집합 필터 적용
// [ ] 안의 PopupFilters는 가독성을 위해 정의한 enum이다.
if (!filtersFromPopup[(int)PopupFilters.STAR_3])
query = query.Except(query.Where(item => item.NumOfStar == 3));
if (!filtersFromPopup[(int)PopupFilters.STAR_4])
query = query.Except(query.Where(item => item.NumOfStar == 4));
if (!filtersFromPopup[(int)PopupFilters.STAR_5])
query = query.Except(query.Where(item => item.NumOfStar == 5));
if (!filtersFromPopup[(int)PopupFilters.JOB_TANKER])
query = query.Except(query.Where(item => item.Job.JobId == 0));
if (!filtersFromPopup[(int)PopupFilters.JOB_DEALER])
query = query.Except(query.Where(item => item.Job.JobId == 1));
if (!filtersFromPopup[(int)PopupFilters.JOB_HEALER])
query = query.Except(query.Where(item => item.Job.JobId == 2));
return query.ToList();
}
코드를 보면 1에서는 isOn 값이 true인지를 체크하고, 2에서는 false인지를 체크한다. 이는 2에서 체크할 토글들의 isOn 기본값이 전부 true이기 때문이다. 그래서 2에서 if문을 타는 조건을 true로 하면 유저가 필터 설정을 바꾸기 전까지 if문 6개를 모두 타게 되고, 이는 화면에 표시할 데이터 수가 많아질수록 처리 속도 면에서 불리할 거라고 생각했다. 그래서 1과 2에서 서로 다른 체크 방식과 필터링 방식을 사용했다.