UIKit, RxSwift
MVVM, Input / Output
Realm, Alamofire, Socket.IO
UITableView + Cursor-based pagination
채팅방에 진입했을 때 해야 할 일은 먼저 화면에 보여주기 위한
채팅 데이터를 불러오는 것 이다.
이 때, 채팅 데이터는 크게 2가지로 나눌 수 있다.
현재 프로젝트에서는 기본적으로 Realm DB에 모든 채팅 데이터를 저장한다.
즉, 읽은 채팅 은 모두 Realm DB에서 꺼내온다.
읽지 않은 채팅 은 서버에 요청에서 받아온다.
var lastChattingDate: Date
lastChattingDate
부터 Date() // 현재 시각
까지의 채팅 데이터를 서버에 요청한다. 이 데이터들이 읽지 않은 채팅 이 된다.채팅방에 들어간 순간, 읽지 않은 채팅들도 모두 읽음 처리가 되어야 하기 때문에,
서버에서 응답받은 위 데이터들은 DB에 바로 저장한다.
모든 채팅 데이터가 DB에 저장이 완료되었을 때,
그제서야 DB에서 데이터를 꺼내서 화면에 보여준다.
채팅방 진입 시, 실시간으로 오는 채팅도 받아야 하기 때문에
소켓을 오픈해주어야 한다. 오픈 시점에 대한 이야기는 일단 패스한다.
채팅방에 처음 들어갔을 때,
스크롤 시점은 "여기까지 읽으셨습니다"
셀이 가운데에 위치하도록 했다.
self.mainView.chattingTableView.scrollToRow(
at: indexPath,
at: .middle,
animated: false
)
따라서, 해당 셀 기준으로 위 / 아래 방향 pagination이 가능해야 한다
위 방향 | 아래 방향 |
---|---|
채팅 데이터는 모두 DB에 저장되어 있기 때문에 지정한 개수만큼 DB에서 꺼내온다
"특정 날짜 이전/이후 로 30개의 채팅 데이터를 꺼낸다"
따라서 pagination의 offset으로 이용할 변수가 필요하다.
var previousOffsetTargetDate: Date?
var nextOffsetTargetDate: Date?
pagination을 통해 채팅 데이터를 불러왔으면, offset 변수를 업데이트한다.
아래 방향 pagination은 기존에도 여러 번 구현해본 적이 있었기 때문에 큰 어려움 없이 할 수 있었다.
배열에 append
로 추가 데이터를 붙이고, tableView.reloadData()
를 실행하면 된다.
처음엔 아래 방향 pagination과 크게 다를 게 없다고 생각했다.
pagination이 일어나는 시점에
arr.insert(contentsOf: newArr, at: 0)
tableView.reloadData()
를 실행했지만, 이러면 순식간에 여러 번 Pagination이 일어난다.
이러한 문제가 발생하는 이유는 (내가 판단했을 때)
tableView.reloadData
가 완료되면
사용자의 스크롤 시점은 기존에 보고있던 cell 위치와 동일해야 한다.
이건 당연히 그래야 하고, 구현 목적에도 일치한다.
이 cell 위치는 tableView의 indexPath를 기준으로 맞춰진다.
즉, 내가 기존에 indexPath [0, 10]
셀을 보고 있었다면
tableView.reloadData()
이후에도 [0, 10]
번째 셀을 보게 된다.
하지만 위로 Pagination을 구현할 때는 배열의 앞에 새로운 데이터를 넣어주기 때문에
tableView.reloadData()
이전과 이후에 [0, 10]
번째 데이터는 다른 데이터가 된다.
예를 들어, 내가 추가로 30개의 데이터를 배열의 맨 앞에 넣어줬을 때,
reload 이전에 보고 있던 셀의 위치가 [0, 10]
이라면
reload 이후에 해당 셀의 위치는 [0, 40]
이 된다.
즉, 내가 보고 있어야 할 셀은 [0, 40]
인데,
새로 넣어준 [0, 10]
번째 데이터를 보고 있으니까
pagination 조건에 또 맞게 되고, 순식간에 여러 번 pagination이 일어나버린다.
prefetchRowsAt 을 이용한다면,
pagination이 실행되는 조건 (ex. indexPath.row == 1
) 이
tableView.reload
이후에도 연속으로 계속 실행된다
scrollViewDidScroll 을 이용해도 역시,
pagination이 실행되는 조건 (ex. contentOffset.y < 100
) 이
tableView.reload
이후에도 연속으로 계속 실행된다.
이 때는 아예 contentOffset.y
값이 커지지가 않고, 음수까지 내려가버리게 된다.
willDisplayRow 도 이하 동문.
CGAffineTransform(scaleX: 1, y: -1)
메서드나 "ReverseExtension" 라이브러리를 통해서 테이블뷰 자체를 상하 반전시키게 되면 쉽게 구현이 가능하다.
하지만 나는 위 / 아래 pagination을 모두 구현시켜야 하기 때문에 이건 의미가 없다.
결국 내가 구현해야 하는 건
새로운 데이터는 배열 앞에 붙이고,
테이블뷰 위에 새로운 셀도 그리지만,
스크롤 위치는 기존과 동일해야 한다
tableView.insertRows 메서드를 이용했다.
사실 위에 새롭게 붙는 데이터에 대해서만 cell을 다시 그려주면 되기 때문에
테이블뷰의 모든 셀을 다시 그리는 tableView.reloadData
를 반드시 사용할 필요는 없다. 그래서 insertRows
메서드를 떠올렸다.
insertRows
메서드를 사용함으로써 위에 겪었던 트러블
(indexPath 기준으로 위치 고정) 을 해결할 수 있었다.
다만 이 때는 새롭게 붙이는 데이터의 개수를 알고 있어야 하기 때문에
기존 메서드의 수정이 필요했다. (completionHandler의 매개변수 cnt
)
viewModel.paginationPreviousData { [weak self] cnt in
let indexPaths = (0..<cnt).map { IndexPath(row: $0, section: 0) }
self?.mainView.chattingTableView.insertRows(at: indexPaths, with: .bottom)
}
채팅방에 진입하면 소켓 통신을 통해 실시간 채팅 응답을 받을 수 있다.
실시간 채팅 응답에 대해 UI적으로 어떻게 반응해야 할 지 고민해보았다.
스크롤 위치가 상대적으로 아래에 있을 때
새로운 채팅을 맨 아래에 보여주고, 스크롤 시점도 맨 아래로 움직인다
스크롤 위치가 상대적으로 위에 있을 때
새로운 채팅을 toast view 형태로 보여주고, 스크롤 시점은 유지한다
뷰를 클릭하거나 스크롤을 아래로 내리면 hidden 처리한다
이 때 "상대적"의 기준은 모호하기 때문에 일단 임의로 설정하였다.
// isBottom이 true 일 때 "상대적으로 아래에 있다"고 판단한다
let isBottom = scrollView.contentSize.height - scrollView.contentOffset.y < 800
case 1 | case 2 |
---|---|
뷰를 클릭하거나 스크롤을 아래로 내리면 toastView hidden
클릭 | 스크롤 |
---|---|
위 과정이 UI적으로 봤을 때는 경우의 수가 2개이지만,
실제 코드로 접근하려고 하면 3개로 늘어난다.
아래 방향 Pagination 이 있기 때문이다
아래 방향 pagination의 완료 여부 에 따라 추가로 분기 처리를 진행했다.
이 처리를 하지 않으면, 채팅 순서가 꼬일 수 있다.
아직 디비에서 채팅 데이터를 모두 꺼내지도 않았는데,
새로 온 채팅을 무작정 VM 배열에 붙이게 되면 순서가 꼬이게 된다
경우의 수
isBottom == true
)스크롤 위치가 상대적으로 위 (isBottom == false
)
&& 아래 방향 pagination 완료 o (isDoneNextPagination == true
)
스크롤 위치가 상대적으로 위 (isBottom == false
)
&& 아래 방향 pagination 완료 x (isDoneNextPagination == false
)
소켓 통신으로 newChat 데이터 응답
tableView에 나타날 데이터 배열 : chatArr (VM)
case 1.
1. (Repository) DB에 newChat 저장
2. (VM) chatArr.append(newChat)
3. (VC) tableView.reloadData()
4. (VC) tableView.scrollToBottom()
case 2.
1. (Repository) DB에 newChat 저장
2. (VM) chatArr.append(newChat)
3. (VC) tableView.reloadData()
2. (VC) showNewMessageToastView(newChat)
case 3.
1. (Repository) DB에 newChat 저장
2. (VC) showNewMessageToastView(newChat)
뷰를 클릭하면 테이블뷰의 맨 아래로 스크롤 시점을 움직여야 한다.
이 때도 역시 아래 방향 pagination의 완료 여부에 따라 분기 처리가 필요하다
디비에서 채팅을 모두 꺼낸 상태가 아니라면, 스크롤 시점을 맨 아래로 내리더라도 최신 데이터를 확인할 수 없기 때문이다
따라서 이 경우에는 디비에 남은 채팅 데이터를 모두 꺼내는 과정 이 필요하다
경우의 수
case 1.
1. (VC) tableView.scrollToBottom()
case 2.
1. (VM) fetchAllNextChattingData
2. (VC) tableView.reloadData()
3. (VC) tableView.scrollToBottom()
채팅을 구현할 때
메세지 수신은 소켓, 메세지 전송은 HTTP 를 이용했다
따라서 전송 버튼을 눌렀을 때, HTTP Request로 채팅 전송 요청을 하게 되고,
성공 응답을 받았을 때 해당 채팅 데이터(newChat
)를 VM 배열에 붙여주는 방식이다.
이 때도 역시 아래 방향 pagination의 완료 여부에 따라 분기 처리가 필요하다.
(newMessageToastView 클릭 로직의 경우와 동일하다)
메세지를 전송하게 되면 결국 소켓으로도 해당 응답을 받기 때문에,
소켓 응답에서는 내가 보낸 메세지를 필터링해주는 작업이 필요하다 (추후 정리)
case 1 | case 2 |
---|---|
경우의 수
case 1.
1. (VM) chatArr.append(newChat)
2. (VC) tableView.reloadData()
3. (VC) tableView.scrollToBottom()
4. (VC) initInputView()
case 2.
1. (VM) fetchAllNextChattingData()
2. (VM) chatArr.append(newChat)
3. (VC) tableView.reloadData()
4. (VC) tableView.scrollToBottom()
5. (VC) initInputView()