본문 바로가기

Swift/UIKit

Tableview pagination

리스트를 구현하다보면 아래 영상과 같이 페이지네이션을 해야할 때 가 많다(거의 다 라고 해야하나..;;)

 

아무튼 앱 개발 공부를 처음 할 때(부트캠프 할 때)는 새로운 배열이 들어올 때 마다 tableView(혹은 CollectionView)를 리로드 해버렸는데

이렇게 하면 이미지뷰에 이미지 할당 할 때 겁나 깜빡깜빡 거려서 메모리고 뭐고를 떠나서 꼴 보기가 싫었다...

 

그래서 페이지네이션 할 때는 추가되는 index에 해당하는 cell indexpath 만 append 해주고 해당 cell만 리로드 해주는 방식으로 구현하며 그 내용을 정리하려고 한다

ViewModel에서의 코드

private var listPageNumber = 0
private var listPageSize = 10

//MARK: - Output
var listHasNext = true //View에서 참고할 프로퍼티
    
private func getSomeList() {
    isLoading.accept(true)
    Task {
        do {
            let entity = try await getListUseCase.execute(pageNumber: listPageNumber,
                                                          pageSize: listPageSize)
            await MainActor.run {
            //새 리스트를 받아오면 해당 리스트를 뷰로 전달한다
                appenList.accept(entity.items)
                isLoading.accept(false)
                
            //리스트를 뷰로 전달한 뒤 hasNext 및 pageNumber 수정
                let lastPage = entity.totalCount / listPageSize
                if lastPage == listPageNumber {
                    listHasNext = false
                } else {
                    listPageNumber += 1
                }
            }
        } catch let error {
            await MainActor.run {
                showAlert.accept(error.errorMessage)
                isLoading.accept(false)
            }
        }
    }
}

 

View에서의 코드

- view프로퍼티 세팅

    private weak var viewModel: NewReviewTabVMable?
    private var surveyList = [Entity.Item]()
    //뷰에 처음 진입하면 바로 호출을 하는데 페이지 뷰는 끝에 닿아있어 두번 호출됨을 방지하기 위해 true로 생성
    private var isPaging = true

- TableView 세팅

    private lazy var listTV: UITableView = {
        let tableView = UITableView()
        tableView.separatorStyle = .none
        tableView.dataSource = self
        tableView.delegate = self
        
        //아이템을 표시할 cell과, 로딩 될 때 하단에 표시할 loadingCell 두 개 추가
        tableView.register(ListTVCell.self, forCellReuseIdentifier: "\(ListTVCell.self)")
        tableView.register(LoadingTVCell.self, forCellReuseIdentifier: "\(LoadingTVCell.self)")
        
        return tableView
    }()
extension TableViewListView: UITableViewDataSource, UITableViewDelegate {
    func numberOfSections(in tableView: UITableView) -> Int {
    //0번재 section은 아이템 list를, 1번째 section은 loadingCell을 보여주기 위해 두 개 return
        return 2
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        //section이 0일 때는 아이템 개수 만큼 return
        if section == 0 {
            return list.count
        }
        
        //section이 1이고 페이징 중이고 다음 페이지가 있을 때 1번 section에 1개의 cell return
        if section == 1 && isPaging && viewModel?.listHasNext == true {
            return 1
        }
        
        return 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        //section이 0일 때는 아이템 cell return
        if indexPath.section == 0 {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "\(ListTVCell.self)", for: indexPath) as? ListTVCell,
                  let item = self.list[safe: indexPath.row] else {
                return UITableViewCell()
            }
            
            cell.setCellContents(item: item)
            return cell
        }
        
        //section이 1일 때는 loadingCell return
        if indexPath.section == 1 {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "\(LoadingTVCell.self)", for: indexPath) as? LoadingTVCell else {
                return UITableViewCell()
            }
            
            cell.startLoading()
            
            return cell
        }
        
        return UITableViewCell()
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offsetY = scrollView.contentOffset.y
        let contentHeight = scrollView.contentSize.height
        let height = scrollView.frame.height
        
        if offsetY > (contentHeight - height) { //table view가 바닥에 맞닿았을 때
            if !isPaging && viewModel?.surveyListHasNext == true { //페이징 중이 아니고 다음 페이지가 있다면
                isPaging = true //페이징 중으로 변경하고
                surveyListTV.reloadSections(IndexSet(integer: 1), with: .none) //로딩 셀을 노출하기 위해 section reload를 하고
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
                    self?.viewModel?.getList(listCase: .survey) //리스트 호출
                }
            }
        }
    }
}

여기서 0.5초 텀을 준 이유는 정확히 원인은 모르겠는데 간혹가다가 두 번 호출 되면서 1번 loading section에 item이 할당되면서 이전 row가 업데이트가 안됐는데 아이템 수 만큼 update하려고 한다면서 죽는 현상이 발생해 안정적으로 0.5초 뒤에 호출 하도록 함

- cell 반영 하는 코드

    func appendList(newList: [Entity.Item]) {
        let appendedCount = self.list.count //새 아이템 추가 전 count 기억
        self.list.append(contentsOf: newList) //새 아이템 append
        
        //만약 새 아이템 추가 전 list가 비어있으면 그냥 reload, 아니라면 else구문 실행
        if appendedCount == 0 {
            listTV.reloadData()
            isPaging = false
        } else {
            var indexPathes = [IndexPath]()
            //appendedCount에 해당하는 index ~ appendedCount + 새로운 아이템 count 미만까지 추가
            for index in appendedCount..<(appendedCount + newList.count) {
                indexPathes.append(IndexPath(row: index, section: 0))
            }
            
            위 indexPathes배열에 해당하는 cell들을 추가하고 리로드
            UIView.performWithoutAnimation {
                listTV.insertRows(at: indexPathes, with: .none)
                listTV.reloadRows(at: indexPathes, with: .none)
            }
            
            isPaging = false
            1번째 section reload(로딩이 끝나 로딩 cell을 숨기기 위함)
            listTV.reloadSections(IndexSet(integer: 1), with: .none)
        }
    }

 

 

이게 최고의 방법이라고 할 수는 없겠지만

지금까지 페이지네이션을 구현할 때마다 코드도 다르고 정리도 안돼서 수정하거나 어디서 문제가 되는지 찾아보기가 너무 힘들었는데 계속 구현하다보니 이제는 어느정도 정리 된 듯 싶어 앞으로 구현할 때 참고도 할 겸 글을 작성한다