왁타버스 뮤직 앱을 개발하면서 가사 기능을 구현했던 내용을 기록하기 위해 작성한 글입니다.
요구사항 및 결과 화면
1. 가사 트래킹 기능: 시간이 흐르면 재생중인 가사가 가운데로 스크롤되어야 함
2. 가사 하이라이팅 기능: 재생중인 가사를 하이라이팅해야 함
3. 스크롤 가사 탐색 기능 : 가사를 스크롤하면 멈춘 위치의 가사를 재생
기능 구현
트래킹 기능
1. JSON 으로 받아온 가사를 가사 딕셔너리로 변환
가사를 요청하면 아래와 같은 형식의 json이 날아옵니다.
[{"identifier":"1","start":17.17,"end":19.9,"text":"기억나 우리 처음 만난 날","styles":""},
{"identifier":"2","start":19.9,"end":23.77,"text":"내게 오던 너의 그 미소가","styles":""},
...
]
이중 사용할 것은 가사가 시작되는 초(seconds)인 start 와 text 입니다.
var lyricsDict = [Float : String]()
var sortedLyrics = [String]()
lyricsEntityArray.forEach { self.lyricsDict.updateValue($0.text, forKey: Float($0.start))
self.sortedLyrics = self.lyricsDict.sorted { $0.key < $1.key }.map { $0.value }
start 를 딕셔너리의 key값으로, 가사를 value로 넣습니다.
가사 딕셔너리와 가사 배열을 각각 만들어두는게 중요합니다.
가사 딕셔너리는 시간을 가사의 위치로 변환할 때 사용되며, 가사 배열은 실질적으로 테이블뷰에 가사를 보여주기 위해 사용됩니다.
2. 시간이 지남에 따라 재생중인 가사 cell 의 index 찾기
여기서의 포인트는 시간을 현재 재생중인 가사의 cell index로 어떻게 변환할지 입니다.
저는 Combine 프레임워크를 사용해 플레이어의 시간이 변경될때마다 output.playTimeValue 로 값이 방출되도록 해두었기 때문에 output.playTimeValue를 구독하면 시간이 지남을 감지하였습니다.
output.playTimeValue
.compactMap { [weak self] time -> Int? in
guard let self = self, time > 0,
!self.viewModel.lyricsDict.isEmpty,
!self.viewModel.isLyricsScrolling else { return nil }
return self.viewModel.getCurrentLyricsIndex(time)
}
.sink { [weak self] index in
self?.trackingLyrics(index: index) // 현재가사로 스크롤
self?.updateLyricsHighlight(index: index)
}
.store(in: &subscription)
compactMap 을 통해 예외처리 및 사용자가 가사를 스크롤 중인 경우를 제외 시키고 시간을 index 로 변환합니다.
가사 딕셔너리의 key가 시간이므로 key로 정렬하고, 딕셔너리의 key(시간) 중 작은쪽으로 가장 가까운 값을 찾습니다.
현재 시간과 key(시간)이 같은 값을 찾지 않고 가장 가까운 값을 찾는 이유는 플레이어의 시간이 변경될때마다 playTimeValue에 시간이 전달되긴 하지만 0.001초 단위로 호출되지 않기 때문에 (기기 성능에 따라 달라질 것으로 생각됨)
장 가까운 값을 찾도록 했습니다. 찾는 방식은 이진탐색을 사용해 O(log n)의 시간 복잡도를 갖게 됩니다.
최종적으로 getCurrerntLyricsIndex() 의 시간복잡도는 딕셔너리의 sorted() 시간 복잡도인 O(n log n) + O(log n) 이 되어 O(n log n)이 됩니다.
func getCurrentLyricsIndex(_ currentTime: Float) -> Int {
let times = lyricsDict.keys.sorted()
let index = closestIndex(to: currentTime, in: times)
return index
}
func closestIndex(to target: Float, in arr: [Float]) -> Int {
var left = 0
var right = arr.count - 1
var closestIndex = 0
var closestDistance = Float.greatestFiniteMagnitude
while left <= right {
let mid = (left + right) / 2
let midValue = arr[mid]
if midValue <= target {
let distance = target - midValue
if distance < closestDistance {
closestDistance = distance
closestIndex = mid
}
left = mid + 1
} else {
right = mid - 1
}
}
return closestIndex
}
3. 찾은 index의 cell로 스크롤
func trackingLyrics(index: Int) {
if !viewModel.isLyricsScrolling {
playerView.lyricsTableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
}
}
사용자가 스크롤하고 있지않을 때만 찾은 index의 cell이 테이블뷰의 중앙에 오도록 스크롤합니다.
하이라이팅 기능
output.playTimeValue
.compactMap { [weak self] time -> Int? in
guard let self = self, time > 0,
!self.viewModel.lyricsDict.isEmpty,
!self.viewModel.isLyricsScrolling else { return nil }
return self.viewModel.getCurrentLyricsIndex(time)
}
.sink { [weak self] index in
self?.trackingLyrics(index: index)
self?.updateLyricsHighlight(index: index) // 가사 하이라이팅
}
.store(in: &subscription)
func updateLyricsHighlight(index: Int) {
// 모든 셀에 대해서 강조 상태 업데이트
let rows = playerView.lyricsTableView.numberOfRows(inSection: 0)
for row in 0..<rows {
let indexPath = IndexPath(row: row, section: 0)
if let cell = playerView.lyricsTableView.cellForRow(at: indexPath) as? LyricsTableViewCell {
cell.highlight(row == index)
}
}
}
cell의 index가 현재 재생중인 가사의 index와 일치하면 하이라이팅, 아니면 하이라이팅을 해제합니다.
스크롤 가사 탐색 기능
1. UIScrollViewDelegate 선언
PlayerViewController: UIScrollViewDelegate {
/// 스크롤뷰에서 드래그하기 시작할 때 한번만 호출
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
}
/// 스크롤 중이면 계속 호출
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
}
/// 손을 땠을 때 한번 호출, 테이블 뷰의 스크롤 모션의 감속 여부를 알 수 있다.
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
}
/// 스크롤이 감속되고 멈춘 후에 작업을 처리
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
}
}
2. 메소드 구현
/// 스크롤뷰에서 드래그하기 시작할 때 한번만 호출
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
viewModel.isLyricsScrolling = true
}
먼저 스크롤하기 시작하면 트래킹을 멈추기 위해 isLyricsScrolling 을 true로 바꿉니다.
/// 스크롤 중이면 계속 호출
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
if viewModel.isLyricsScrolling {
findCenterCellIndexPath { centerCellIndexPath in
updateLyricsHighlight(index: centerCellIndexPath.row)
}
}
}
스크롤되는 동안은 트래킹 되지 않기 때문에 가운데 셀을 하이라이팅합니다.
가운데 셀을 찾는 fendCenterCellIndexPath 메소드는 다음과 같이 구현되어있습니다.
/// 화면에서 가장 중앙에 위치한 셀의 indexPath를 찾습니다.
func findCenterCellIndexPath(completion: (_ centerCellIndexPath: IndexPath) -> Void) {
let centerPoint = CGPoint(x: playerView.lyricsTableView.center.x,
y: playerView.lyricsTableView.contentOffset.y + playerView.lyricsTableView.bounds.height / 2)
// 가운데 셀의 IndexPath를 반환합니다.
guard let centerCellIndexPath = playerView.lyricsTableView.indexPathForRow(at: centerPoint) else { return }
completion(centerCellIndexPath)
}
/// 손을 땠을 때 한번 호출, 테이블 뷰의 스크롤 모션의 감속 여부를 알 수 있다.
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
findCenterCellIndexPath { centerCellIndexPath in
if viewModel.lyricsDict.isEmpty { return }
let start = viewModel.lyricsDict.keys.sorted()[centerCellIndexPath.row]
player.seek(to: Double(start), allowSeekAhead: true)
viewModel.isLyricsScrolling = false
}
}
}
/// 스크롤이 감속되고 멈춘 후에 작업을 처리
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
findCenterCellIndexPath { centerCellIndexPath in
if viewModel.lyricsDict.isEmpty { return }
let start = viewModel.lyricsDict.keys.sorted()[centerCellIndexPath.row]
player.seek(to: Double(start), allowSeekAhead: true)
viewModel.isLyricsScrolling = false
}
}
스크롤이 끝나면 가사 딕셔너리 keys에서 가운데 cell의 인덱스와 일치하는 key를 찾아, player 의 원하는 부분을 재생합니다.
'iOS' 카테고리의 다른 글
XCode Archive 시 has no member Error 발생 (0) | 2024.03.04 |
---|---|
MPNowPlayingInfoCenter 제어센터 초기세팅 및 갱신하는 방법 (0) | 2023.05.16 |
Tuist Project 'Could not build Objective-C module' 오류 해결법 (0) | 2023.01.12 |
[SwiftUI] 사라질 때 transition이 적용안되는 경우 (SwiftUI removal transition not animated) (0) | 2022.11.29 |
CocoaPods pod install 시 minimum deployment target 경고가 뜰 때 (0) | 2022.11.07 |