티스토리 뷰

뮤직 플레이어 앱을 포함한 미디어 관련 서비스에는 유저가 재생중인 미디어의 타임라인을 조절하는 기능이 있다.

Seekbar 라는 이름으로 많이 부른다.

 

FLO 뮤직 플레이어 앱에서도 물론 Seekbar가 존재한다.

플레이어 컨트롤러 버튼들과 마찬가지로 MusicPlayer의 정보를 보여주며, 유저 인터랙션을 통해 싱글톤 객체를 컨트롤 하는 역할이다.

 

나는 PlayerSeekbar 클래스를 만들어서 각 뷰에 넣어주었다.

이번 글에서는 FLO 뮤직플레이어 앱에서 어떻게 Seekbar를 구현했는지 작성하고자 한다.

 

 

1. Seekbar의 기능

- MusicPlayer의 currentTime을 바인딩해 UI로 표시

- MusicPlayer의 currnetPlaybackRatio(재생 비율)을 바인딩해 해당 비율만큼 시간의 흐름을 표시함

- 유저가 seekbar를 touch 시작 할 경우 height가 늘어나며, 현재 터치중인 영역의 시간을 표시함

- 유저가 touch out 한 곳으로 미디어의 타임라인을 이동시킴

 

 

2. 타임라인 바(막대) 뷰 구현

- 간단하게 horizon axis를 가진 stackView로 구현

- 좌측 부터 천천히 올라가는 보라색 타임라인의 widthAnchor를 비율에 맞춰 제약조건 update

- 좌측 view의 widthAnchor에 따라 남은 타임라인의 길이는 자동 조절됨

 

3. 실시간 터치 입력 받기(⭐️유저 인터랙션)

-> 유저의 인터랙션 데이터를 가장 처음 받는 중요한 역할

 

- 유저의 터치 시작, 터치 후 슬라이딩, 터치 종료 총 3가지 이벤트를 받아야 함

- 하지만, seekbar는 좌측과 우측의 현재 시간, 총 duration label을 포함한 뷰로 만들었음

( seekbar != timelineBar )

- 타임라인만 터치를 허용하도록 구현해야 함 ! 이때 내가 사용한 메소드는 hitTest!

    // 터치 허용 영역 처리
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // 타임라인의 rect만 터치를 허용함
        if !self.timeLineStackView.frame.contains(point) {
            return nil
        }
        return super.hitTest(point, with: event)
    }

- 터치 이벤트를 해당 뷰에서 가져갈 것인지, 안가져갈 것인지(return nil) 를 처리해주는 메소드

- 터치한 Point가 timeLineStackView의 프레임 영역 내부에 포함되는 경우만 터치를 가져감

- nil을 리턴한 경우 seekbar 안쪽으로 터치 이벤트가 전달됨(물론 안쪽에 인터랙션 뷰가 없기 때문에 동작 없음)

 

- 이외의 터치 컨트롤 메소드

    // 터치 시작
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        self.isTimelineTapped.accept(true)
        self.acceptTimeline(withTouch: touches.first)
    }

	// 터치 후 이동
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesMoved(touches, with: event)
        self.acceptTimeline(withTouch: touches.first)
    }
    
    // 손을 뗏을 때
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        self.isTimelineTapped.accept(false)
    }
    
    // 터치가 중단됐을 때
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesCancelled(touches, with: event)
        self.isTimelineTapped.accept(false)
    }
    
    private func acceptTimeline(withTouch touch: UITouch?) {
        guard let touch = touch else { return }
        // 타임라인의 전체 길이
        let timelineWidth = self.timeLineStackView.frame.width
        // 현재 탭한 타임라인의 위치
        let tappedPoint = touch.location(in: self.timeLineStackView).x
        // 0과 timelineWidth (예: 300) 사이의 값으로 currentTimelinePoint 제한
        let currentTimelinePoint = min(max(0, tappedPoint), timelineWidth)
        self.timelinePoint.accept(currentTimelinePoint)
    }

- seekbar 내부에 타임라인의 터치 상태 값(Bool), 실시간 터치 위치(CGFloat) 을 내부 Observable에 accept 함

 

4. 얻은 데이터로 UI 인터랙션

구현할 UI 인터랙션

- FLO앱의 타임라인은 터치를 시작할 때 타임라인의 높이가 증가함 (애니메이션)

- 터치한 곳의 타임코드(seekTimeCode)가 그 위에 표시되며 터치 위치를 따라다님

- 따라다니지만, seekTimeCode의 양쪽이 seekbar의 leading, trailing을 벗어날 수는 없음

 

앞서 TouchControl Methods에서 두가지 데이터를 observable로 받는다.

- isTimeLineTapped: Bool - 타임라인에 손을 올리고 있는 상태인지?

- timelinePoint: CGFloat - 현재 터치한 타임라인의 위치

 

전체적으로는 이런 구조

입력한 observable을 바로 구독하여 활용할 로직은 다음과 같다.

 

- 터치한 position값을 timeline의 width값에 적용 + 위치값을 초 로 변환하여 텍스트에 넣기

[ 탭한 포지션 : 타임라인 총 width = 현재 플레이 시간 : 전체 duration >\] 을 이용

/// 타임라인의 position을 초 단위로 환산
var secondTimelineTapped: Int? {
    guard let totalSecond = self.musicDuration else { return nil }
    let ratio = self.totalTimelineWidth / self.lastTappedPoint
    let tappedSecond = Int(totalSecond / ratio)
    return tappedSecond
}

// 유저 인터랙션으로 이동되는 타임라인을 바인딩
self.timelinePoint
    .bind { [weak self] point in
        guard let self = self else { return }
        self.lastTappedPoint = point
        // 타임라인 위치 설정
        self.timeLineWidthConstraint.constant = point
        // 타임코드 가 슬라이드를 따라가도록 설정
        self.configureTimecodePosition(tapPoint: point)
        // 지금 터치한 곳의 초 구하기
        guard let tappedSecond = self.secondTimelineTapped else { return }
        // 타임코드 텍스트에 넣기
        self.configureTimecodeString(withTappedSecond: tappedSecond)
    }
    .disposed(by: disposeBag)

 

- 터치한 position값을 통한 타임코드의 위치 이동 

- 이때 timecode가 stackView의 leading, trailing을 벗어나지 않게 하기 위해 로직 처리가 필요함

- timecode의 width * 0.5 값을 이동 가능한 포지션의 min, max 로 설정했음

/// 타임라인 탭 했을 때 나오는 타임코드의 위치 이동
private func configureTimecodePosition(tapPoint: CGFloat) {
    let timecodeCenterPosition = self.timeLineStackView.frame.width / 2

    let min = -timecodeCenterPosition + self.seekTimecodeWidth / 2
    let max = timecodeCenterPosition - self.seekTimecodeWidth / 2

    // 적용할 Offset에서 최소값, 최대값 설정
    var centerOffset = -(timecodeCenterPosition - tapPoint)
    centerOffset = centerOffset < min ? min : centerOffset
    centerOffset = centerOffset > max ? max : centerOffset

    // 위치 업데이트
    self.seekTimecode.snp.updateConstraints {
        $0.centerX.equalTo(self.timeLineStackView).offset(centerOffset)
    }
}

 

5. MusicPlayer의 실시간 재생 정보 바인딩

- 현재 플레이중인 음악의 실시간 재생 정보를 받아와 UI에 표시해주는 작업

- MusicPlayer는 1초에 한번씩 currentSecond에 데이터를 업데이트 하고 있음

- currentPlaybackRatio(재생비율)을 timeline의 widthAnchor에 적용함

- 유저가 탭 한 상태인 경우에는 작동이 안되도록 제외함 (guard문)

 

    /// AVPlayer에서 받아온 플레이중인 현재 시간 바인딩
    /// -> 현재 재생중인 시간 에 적용
    MusicPlayer.shared.currentSecond
        .bind { [weak self] second in
            guard let self = self else { return }
            // 현재시간 변경
            let timecode = self.secondToTimecode(second: Int(second))
            self.currentPlaybackTimeLabel.text = timecode
        }
        .disposed(by: disposeBag)

    /// AVPlayer에서 받아온 Playback 비율 바인딩
    /// -> 타임라인 뷰 width에 적용
    MusicPlayer.shared.currentPlaybackRatio
        .bind { [weak self] ratio in
            guard let self = self,
                  self.isTimelineTapped.value != true else { return }
            let width = self.totalTimelineWidth * CGFloat(ratio)
            self.timeLineWidthConstraint.constant = width
        }
        .disposed(by: disposeBag)

 

6. 적용하기

이렇게 Seekbar 클래스가 완성되었다. Seekbar는 PlayerController와 LyricsContoller에 서로 다른 인스턴스로 각각 적용했다.

적용은 상당히 간단하나 고려해야 할 부분이 있다.

 

👉 PlayerController

1. 인스턴스를 생성하고

2. 레이아웃을 잡고

3. 음악 재생이 시작 되었을 때 configure를 해준다.

private let seekbar = PlayerSeekbar(isShowTimeInfo: false)

...

    self.view.addSubview(self.seekbar)
    self.seekbar.snp.makeConstraints {
        $0.leading.trailing.equalToSuperview().inset(16)
        $0.bottom.equalTo(playerControlStackView.snp.top).inset(10)
        $0.height.equalTo(40)
    }
    
...

    MusicPlayer.shared.start(musicUrl: music.file) {
        self.seekbar.configureSeekbar()
    }

👉 LyricController

- start함수에 PlayitemStatus가 묻어있기 때문에 PlayerController와 동일하게 사용하기는 어려웠다.

- seekbar.configureSeekbar 함수만 단독으로 불러야 하는데, viewDidLoad에 호출했을 경우 이슈가 있었다.

위의 GIF 처럼 LyricController가 실행 되었을 때 seekbar 가 0 -> 현재위치로 되는 이슈다.

 

왜 그럴까? 고민을 해보니 뷰의 생명주기와 관련있었다.

- configureSeekbar에는 seekbar 내부에 bind 함수가 같이 호출된다.

- 이때 MusicPlayer의 currentTime은 데이터가 이미 저장되어 있기 때문에 즉시 데이터를 방출한다.

- viewDidLoad에서는 뷰가 로드는 되었지만, 레이아웃이 아직 잡히지 않았다.

- seekbar는 자기자신의 frame.width 를 기반으로 작동하기 때문에, frame이 0으로 잡힌 seekbar는 데이터를 표시하지 못했다.

- 그래서 그 다음 currentTime이 방출되는 타이밍에서야 정상적으로 업데이트 되는 것이다.

그리고 그 증거 (LyricController에서 viewDidLoad의 configureSeekbar 부른 타이밍)

그렇다면? 뷰의 레이아웃이 자리잡은 다음에 configureSeekBar를 불러주면 된다.

그래서 ! viewDidLoad가 아닌, viewDidAppear에서 함수를 불러주었다.

 

seekbar는 비율로 작동하기 때문에 화면 크기에 상관 없이 정상적으로 작동한다.

 

구현해보고 싶었던 두가지 중 하나, (나머지는 가사 스크롤!)

나름 많은 고민이 들어갔던 기능이다.

 

MusicPlayer가 가지고 있는 상태값과,

seekbar가 가지고 있는 상태값 등이 있는데 

이 데이터들을 어떻게 나누고, 어떻게 관리를 하면 좋을지 많은 고민을 했다.

 

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

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