티스토리 뷰

 

내가 구현하는 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
        }
    }
}

 

소스코드: https://github.com/jisu15-kim/FloMusicPlayer

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/12   »
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
글 보관함