티스토리 뷰
내가 구현하는 FLO 앱의 두가지 뷰에는 모두 가사가 들어있으며, 시간에 맞춰 자동으로 스크롤된다.
Player 뷰 에서는 두줄을 보여주며 스크롤 되며, 가사 영역을 터치할 경우 가사가 크게 나온다.


1. 기능 분석
플레이어 뷰(좌측)
- 오토 스크롤
- 하이라이팅(흰 글씨)
- 가운데 정렬
- 유저 터치시 가사뷰로 이동
가사 뷰(우측)
- 오토 스크롤
- 하이라이팅(흰 글씨)
- 좌측 정렬
- 유저 터치시 해당 영역으로 플레이어 이동
- 유저의 스크롤도 가능
- 가사 컨트롤 버튼 존재
플레이어 뷰의 기능에서 더 확장된 것이 가사 뷰 라고 할 수 있겠다.
동일한 클래스를 사용할 수 있도록 LyricTableView를 만들고, 각 뷰별로 해당하는 옵션을 넣어주도록 하자.
2. 모델 만들기
먼저 테이블 뷰에 가사를 띄우기 위해 모델을 만들어야 한다.
특히 나는 모델이 이런식으로 구성이 되는 것을 좋아하고, 사용할 때 편할 것 같다.
struct PlayableMusic: Codable {
let singer: String
let album: String
let title: String
let duration: Int
let image: String
let file: String
let lyrics: [PlayableMusicLyricInfo]
}
struct PlayableMusicLyricInfo: Codable {
let second: Double?
let lyric: String
}
하지만 JSON 으로 들어오는 가사는 배열 형태로 되어있지 않고, 모든 가사 데이터들이 하나의 문자열로 들어온다.
그래서 JSON Decodable을 커스텀하여 JSON이 파싱 될 때 문자열도 쪼개고 나눠서 내가 원하는 모델로 들어오도록 구현했다.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.singer = try container.decode(String.self, forKey: .singer)
self.album = try container.decode(String.self, forKey: .album)
self.title = try container.decode(String.self, forKey: .title)
self.duration = try container.decode(Int.self, forKey: .duration)
self.image = try container.decode(String.self, forKey: .image)
self.file = try container.decode(String.self, forKey: .file)
let lyricsString = try container.decode(String.self, forKey: .lyrics)
let lyricsLines = lyricsString.split(separator: "\n")
var lyricInfos: [PlayableMusicLyricInfo] = []
for line in lyricsLines {
let components = line.split(separator: "]")
if components.count == 2 {
let timecode = String(components[0]).replacingOccurrences(of: "[", with: "")
let lyric = String(components[1])
let second = PlayableMusic.convertTimeToDouble(timecode)
let lyricInfo = PlayableMusicLyricInfo(second: second, lyric: lyric)
lyricInfos.append(lyricInfo)
}
}
lyrics = lyricInfos
}
그래서 결과적으로 lyrics 테이블뷰 셀에는 해당 가사의 플레이 시작 시간과 가사 String이 가진다.
3. 시간에 맞춰 스크롤 되도록 구현
LyricTableView 는 MusicPlayer.shared의 currentSecond(현재 재생 초, 1초마다 데이터 방출)를 구독하게 된다.
그렇다면, 현재 플레이 시간을 가지고 어떤 가사로 스크롤 할 지 파악해서 스크롤 시키면 되겠다.
어떻게 만드는 것이 좋을지 많은 고민이 들었던 부분이다.
나는 두가지 함수로 나누어서 이 기능을 만들었다.
// 옵저버에서 획득한 초에 맞는 가사를 꺼냄
private func configureTimecode(currentSecond: Double) {
let lyrics = self.viewModel.playableMusicInfo.value
var currentLyric: PlayableMusicLyricInfo?
var currentIndex: Int?
for (index, lyric) in lyrics.enumerated() {
if let second = lyric.second, currentSecond >= second {
currentLyric = lyric
currentIndex = index
} else {
break
}
}
self.configureLyricsScroll(index: currentIndex, lyric: currentLyric)
self.currentHighlitingIndex = currentIndex
}
- 먼저, configureTimecode 함수에서는 currentSecond를 파라미터로 받는다. (1초마다 불릴 것이다.)
- 가사 데이터 배열을 순회하는 for문을 돌린다.
- 가사 데이터의 시간정보(second) 보다 현재의 시간이 더 크다면 그 데이터를 변수에 저장한다.
- 그렇게 지속적으로 각 배열을 반복하다보면 현재의 시간이 더 작을 수 있는데, 그때는 for문을 break 한다.
- 그 결과 마지막으로 저장된 가사 모델과 index를 얻을 수 있다.

이 함수의 기능은 여기까지이다.
마지막으로 저장된 가사 모델과 index는 configureLyricsScroll 함수로 보내 처리한다.
/// 가사 스크롤
private func configureLyricsScroll(index: Int?, lyric: PlayableMusicLyricInfo?) {
// 오토 스크롤 상태인지 체크
var isAutoScrollEnable = self.viewModel.lyricViewConfig.isAutoScrollEnable
if self.viewModel.lyricViewConfig == .inLyricView {
isAutoScrollEnable = self.viewModel.autoScrollStatus.value.value
}
// 데이터가 있다면?
guard let index = index,
let lyric = lyric else {
// 데이터가 없다면 가사가 나오기도 전 이라는 것 -> 맨 처음으로 이동
if isAutoScrollEnable {
self.tableView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
}
self.configureLyricsHighlight(targetLyric: nil)
return
}
// 오토 스크롤 상태일 때 && 중복 스크롤이 불리지 않도록 방어
if isAutoScrollEnable && !self.isAnimatingScroll {
UIView.animate(withDuration: 0.3, animations: { [weak self] in
guard let self = self else { return }
self.isAnimatingScroll = true
self.tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: self.viewModel.lyricViewConfig.authScrollposition, animated: true)
}) { [weak self] _ in
self?.isAnimatingScroll = false
}
}
self.configureLyricsHighlight(targetLyric: lyric)
}
- 현재 오토스크롤 가능한 상태인지 체크해 함수 내부에 저장한다. (이 값으로 스크롤을 하거나 하지 않는다)
- config에 따라서 플레이어뷰에서는 true, 가사뷰에서는 false가 기본 값이지만, 가사뷰 에서는 우측 가사 컨트롤 패널의 토글 상태값으로 덮어씌운다.
이후 파라미터로 받은 가사 모델과 index를 언래핑한다.
- 이때, 데이터가 nil 이라는 것은 곧 첫번째 가사도 나오기 전 이라는 것이기 때문에 맨 처음으로 이동시킨다!
- 그럼 이제 언래핑한 데이터를 가지고 tableView가 스크롤 되도록 하면 된다 !
처음에는 그냥 tableView의 scrollToRow 함수만 불렀었다.
물론 정상 작동은 되었으나! 때에 따라 중복으로 스크롤이 불려 애니메이션이 꼬이는 경우가 있었다.
그래서 UIView의 animate 함수에 scrollToRow를 포함시키고, completion을 받아 플래그를 만들어 활용했다.
그 결과 오토 스크롤은 모두 정상적으로 동작한다. 🔥
4. 가사 탭 했을 때 해당 위치 재생하기
// 유저 가사 탭 인터랙션 바인딩
self.tableView.rx.modelSelected(PlayableMusicLyricInfo.self)
.bind { [weak self] lyric in
guard let self = self else { return }
if self.viewModel.tapToSeekStatus.value == .enable {
guard let second = lyric.second else { return }
MusicPlayer.shared.seek(seekSecond: second)
} else {
self.delegate?.needViewDismiss()
}
}
.disposed(by: disposeBag)
LyricTableView는 RxCocoa로 바인딩 되었다.
탭 이벤트가 들어온 경우, 해당 셀 내부의 모델의 시간 데이터값을 MusicPlayer의 seek 함수로 보내기만 하면 된다.
seek 함수를 보내는 즉시 3번에서 설명한 currentSecond가 갱신되어 방출되게 되고,
구현해놓았던 코드로 인해 스크롤, 하이라이팅이 된다 !


5. 각 뷰별로 lyricTableView의 config 설정하기
LyricTableView라는 클래스는 플레이어뷰와 가사뷰 두곳에서 각각의 인스턴스로 사용된다.
나는 enum으로 두가지의 옵션을 분기처리해 관리했다.
또한, LyricTableView는 LyricTypeConfig에 의존성을 가지고 생성된다.
혹시 또 다른 뷰에 테이블뷰가 필요하다면 그 뷰에 해당하는 case를 추가해서 가지고 들어가면 좋을 것 같다.
enum LyricsTypeConfig {
case inPlayerView
case inLyricView
}
extension LyricsTypeConfig {
/// 가사 셀의 높이
var heightForRowAt: CGFloat {
switch self {
case .inPlayerView: return 20
case .inLyricView: return 25
}
}
/// 스크롤 허용 여부
var isScrollEnable: Bool {
switch self {
case .inPlayerView: return false
case .inLyricView: return true
}
}
var isAutoScrollEnable: Bool {
switch self {
case .inPlayerView: return true
case .inLyricView: return false
}
}
/// 텍스트 폰트
var lyricFont: UIFont {
switch self {
case .inPlayerView: return .systemFont(ofSize: 15, weight: .regular)
case .inLyricView: return .systemFont(ofSize: 16, weight: .regular)
}
}
/// 텍스트 정렬
var textAlighment: NSTextAlignment {
switch self {
case .inPlayerView: return .center
case .inLyricView: return .left
}
}
/// 우측 버튼 보이는 여부
var isShowControlButton: Bool {
switch self {
case .inPlayerView: return false
case .inLyricView: return true
}
}
var authScrollposition: UITableView.ScrollPosition {
switch self {
case .inPlayerView: return .top
case .inLyricView: return .middle
}
}
}
'iOS' 카테고리의 다른 글
| [iOS] DateFormatter 연도가 잘못 나올 때(주 기반 연도, YYYY) (2) | 2024.01.02 |
|---|---|
| [iOS] Watch Connectivity 데이터 전송이 안될 때 (기타 이슈들) (0) | 2023.11.11 |
| [iOS] FLO 앱 만들기(4) - Seek 뷰와 기능 만들기 (0) | 2023.10.20 |
| [iOS] FLO 앱 만들기(3) - AVPlayer의 상태값 받아오기(KVO) (0) | 2023.10.16 |
| [iOS] FLO 앱 만들기(2) - 재사용을 고려한 플레이어 버튼 만들기 (0) | 2023.10.14 |
- Total
- Today
- Yesterday
- keyboardtype
- audiokit
- AVFoundation
- demical
- easy cue
- self-hosted-runner
- flo
- ios채팅
- openapi-generator
- swift audio
- watch connectivity
- IOS
- onTapGesture
- swiftui 제스처
- open-api-generator
- swiftui 탭
- SwiftUI
- audio kit
- DateFormatter
- swift날짜
- 맥북에어 m4
- string catalog
- ios 다국어
- Xcode15
- Github action
- Swift
- highprioritygesture
- ios웹소켓
- avplayer
- 애플워치 데이터 전송
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |