引言
在之前的博客中,我们已经介绍了如何实现一个简单的播放器,并通过监听资源和播放器的属性来提升播放体验。因此本篇博客将带你进一步自定义播放器 UI。通过构建自己的播放控制界面(如播放/暂停按钮、进度条、全屏切换等),我们能够提供更符合需求的播放体验,并且支持用户通过手势操作进行更精细的控制。
播放画面的视图
在正式介绍“播放/暂停按钮”之前,我们先来看一下播放器的画面是如何展示在视图上的。
我们通过自定义的 PHPlayerView,使用 AVPlayerLayer 来承载 AVPlayer 的视频内容。通过覆写 layerClass 属性,让 PHPlayerView 的底层图层类型变为 AVPlayerLayer,从而直接承接播放画面。
import UIKit
import AVFoundationclass PHPlayerView: UIView {override class var layerClass: AnyClass {return AVPlayerLayer.self}/// 设置播放器/// - Parameter player: 播放器func setPlayer(_ player: AVPlayer) {guard let layer = self.layer as? AVPlayerLayer else { return }layer.player = player}
}
当然了可以直接创建AVPlayerLayer添加到图层之上。有了这个播放画面视图之后,我们就可以继续搭建播放控制相关的 UI,比如播放/暂停按钮。
协议
为了让项目结构清晰,我们定义了两个关键的协议,来解决播放控制器与播放控制视图组件耦合的问题。
PHPlayerProtocol
该协议为播放器的代理协议,协议内定义了播放器相关的内容发生变化时需要指定的方法,而需要监听变化的UI组件需要遵守该协议,并实现协议方法。目前协议方法包含了播放状态的改变以及播放进度的改变。具体代码如下:
import Foundationprotocol PHPlayerProtocol:NSObjectProtocol {/// 播放状态发生变化/// - Parameter status: 播放状态func playerStatusDidChange(status: PHPlayerStatus)/// 播放进度/// - Parameters:/// - Parameter currentTime: 当前时间/// - Parameter totalTime: 总时间func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval)}
PHControlProtocol
该协议为播放UI组件的代理协议,协议内定义了播放UI组件对播放控制器操作的方法,而播放控制器需要遵循该协议,并实现这些方法。目前协议内包含了播放、暂停、快进到指定时间三个方法。具体代码如下:
import Foundationprotocol PHControlProtocol:NSObjectProtocol {/// 播放func play()/// 暂停func pause()/// 指定位置播放/// - Parameter time: 时间func seekTo(time: TimeInterval)}extension PHControlProtocol {func play() {}func pause() {}func seekTo(time: TimeInterval) { }
}
自定义播放控制 UI
我们将除播放视图以外的部分,分成两个部分:
- PHPlayerInfoView:视频信息和返回按钮。
- PHPlayerControlView:视频的自定义播放组件UI。
而这两部分,我们选择一个专门的视图 PHPlayerOverlayView 用来承载,与播放画面的视图完全隔离。
整个结构如下图所示:
代码如下:
import UIKitclass PHPlayerOverlayView: UIView, PHPlayerProtocol {/// 视频信息let videoInfoView = PHPlayerInfoView()/// 控制视图let controlView = PHPlayerControlView()override init(frame: CGRect) {super.init(frame: frame)setupView()setLayout()}required init?(coder: NSCoder) {fatalError("init(coder:) has not been implemented")}private func setupView() {// 视频信息self.addSubview(videoInfoView)// 添加控制视图self.addSubview(controlView)}private func setLayout() {// 视频信息videoInfoView.snp.makeConstraints { make inmake.leading.trailing.equalToSuperview()make.top.equalToSuperview()make.height.equalTo(MW_NAVIGATIONBAR_HEIGHT)}// 控制视图controlView.snp.makeConstraints { make inmake.bottom.equalToSuperview()make.leading.trailing.equalToSuperview()make.height.equalTo(125.0 + MW_BOTTOM_SAFE_HEIGHT)}}....
}
我们先以主要功能为主,依次来介绍实现 “播放/暂停按钮” 、“进度条(UISlider)”、“当前时间/总时间显示”、“播放完成后的重播/状态提示”。
播放/暂停按钮
在控制播放功能时,我们通过自定义 UIButton 来实现对 AVPlayer 的播放与暂停控制。同时结合播放器的播放状态回调,动态更新按钮的图标,切换播放/暂停的视觉状态。
具体的控制 UI 我们集中封装在PHPlayerControlView中,该类负责承载播放控制相关的所有交互组件,例如播放/暂停按钮、进度条、时间标签等。
class PHPlayerControlView: UIView {..../// 播放按钮let playButton = UIButton(type: .custom).../// 代理weak var delegate: PHControlProtocol?override init(frame: CGRect) {super.init(frame: frame)setupView()setLayout()setEvent()}private func setupView() {...// 播放、暂停按钮self.addSubview(playButton)playButton.setImage(UIImage(named: "ph_player_play"), for: .normal)playButton.setImage(UIImage(named: "ph_player_pause"), for: .selected)...}private func setLayout() {...// 播放按钮playButton.snp.makeConstraints { make inmake.leading.equalTo(currentTimeLabel)make.top.equalTo(currentTimeLabel.snp.bottom).offset(8.0)make.width.height.equalTo(40.0)}...}private func setEvent() {// 播放、暂停playButton.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside)...}/// 播放、暂停按钮点击事件@objc private func playButtonTapped() {if playButton.isSelected {delegate?.pause()} else {delegate?.play()}}
- 点按钮的点击事件发生时,判断当前按钮状态。
- 如果是播放状态则代理执行暂停操作。
- 如果是暂停状态则代理执行播放的方法。
而状态的同步,则是通过 playerStatusDidChange 方法进行同步,我们使 PHPlayerOverlayView 遵循了 PHPlayerProtocol 这个协议,然后在 PHPlayerControlView 定义了同名方法。
class PHPlayerOverlayView: UIView, PHPlayerProtocol {..../// 播放状态改变/// - Parameter status: 播放状态func playerStatusDidChange(status: PHPlayerStatus) {// 同步控制视图self.controlView.playerStatusDidChange(status: status)}/// 播放进度/// - Parameters:/// - Parameter currentTime: 当前时间/// - Parameter totalTime: 总时间func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval) {// 同步控制视图self.controlView.playerDidProgress(currentTime: currentTime, totalTime: totalTime)}}
该方法在 PHPlayerControlView 中的实现如下:
/// 播放状态改变/// - Parameter status: 播放状态func playerStatusDidChange(status: PHPlayerStatus) {if status == .playing {playButton.isSelected = true// 如果是暂停、完成、失败} else if status == .paused || status == .completed || status == .failed {playButton.isSelected = false}}
- 会根据播放器的状态来修改播放按钮的状态。
进度条(UISlider)
播放器的进度条通常承担多个功能,除了用来实时显示当前播放进度之外,也支持用户拖拽以跳转到任意时间点,还可以同步展示缓冲进度。
在本篇中,我们将聚焦于最新核心的播放控制功能——进度显示与拖拽操作。
创建UISlider时,我们可以进行灵活的自定义,比如圆点的颜色,圆点的图片。当前进度的颜色,以及默认的轨道颜色等等。
/// sliderlet slider = UISlider()// sliderself.addSubview(slider)slider.minimumValue = 0slider.maximumValue = 1slider.setThumbImage(UIImage(named: "ph_player_slider_thumb"), for: .normal)
在进行进度同步时,我们通过实现 playerDidProgress(currentTime:totalTime:) 方法来更新 UISlider 的 minimumValue、maximumValue以及value属性。
/// 播放进度/// - Parameters:/// - Parameter currentTime: 当前时间/// - Parameter totalTime: 总时间func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval) {guard totalTime > 0 else { return }...// 设置sliderslider.minimumValue = 0slider.maximumValue = Float(totalTime)slider.value = Float(currentTime)}
- 根据播放控制器传递过来的当前时间和总时间来更新UISlider的进度值。
为 UISlider 添加开始拖拽、拖拽、结束拖拽三个方法,并在三个方法内通过 delegate ,让播放控制执行对应的暂停、快进、播放操作。
private func setEvent() {...// 开始拖拽slider.addTarget(self, action: #selector(sliderTouchDown), for: .touchDown)// 拖拽slider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)// 结束拖拽slider.addTarget(self, action: #selector(sliderTouchUpInside), for: .touchUpInside)}/// slider开始拖拽@objc private func sliderTouchDown() {// 暂停delegate?.pause()}/// slider拖拽@objc private func sliderValueChanged() {...delegate?.seekTo(time: currentTime)}/// slider结束拖拽@objc private func sliderTouchUpInside() {...// 拖拽结束,开始播放delegate?.seekTo(time: currentTime)delegate?.play()}
- 当拖拽开始时执行暂停操作,边拖拽边播放的现象会很奇怪。
- 在拖拽的过程中播放器始终处于暂停状态,但会快进到指定时间点。
- 当拖拽结束后,在指定的时间点开始播放。
当前时间/总时间显示
这两组UI元素就比较简单了,因为他们不涉及任何交互,只是单方面的用来显示播放器的播放时间状态,而我们需要同步的其实有两个地方。
- 播放器正常播放的进度回调。
- 发生拖拽时,拖拽过程中当前时间的变化。
/// 当前时间let currentTimeLabel = UILabel()/// 总时间let totalTimeLabel = UILabel()// 当前时间self.addSubview(currentTimeLabel)currentTimeLabel.textColor = .whitecurrentTimeLabel.font = UIFont.systemFont(ofSize: 14)currentTimeLabel.text = "00:00"// 总时间self.addSubview(totalTimeLabel)totalTimeLabel.textColor = .whitetotalTimeLabel.font = UIFont.systemFont(ofSize: 14)totalTimeLabel.text = "00:00"
在播放进度发生变化的回调中处理时间显示:
/// 播放进度/// - Parameters:/// - Parameter currentTime: 当前时间/// - Parameter totalTime: 总时间func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval) {guard totalTime > 0 else { return }// 设置当前时间currentTimeLabel.text = currentTime.toHMSTimeString()// 设置总时间totalTimeLabel.text = totalTime.toHMSTimeString()...}
在拖拽过程中对时间的显示处理:
/// slider拖拽@objc private func sliderValueChanged() {// 设置当前时间let currentTime = TimeInterval(slider.value)currentTimeLabel.text = currentTime.toHMSTimeString()...}
其中 toHMSTimeString() 方法是我们为 TimeInterval 添加的扩展方法,用来将时间转换为时分秒格式的字符串。
extension TimeInterval {/// 转成时:分:秒 没有时则是分:秒/// - Returns: 时:分:秒func toHMSTimeString() -> String {let totalSeconds = Int(self)let hours = totalSeconds / 3600let minutes = (totalSeconds % 3600) / 60let seconds = totalSeconds % 60if hours > 0 {return String(format: "%02d:%02d:%02d", hours, minutes, seconds)} else {return String(format: "%02d:%02d", minutes, seconds)}}
}
播放完成后的重播/状态提示
在视频播放完成之后呢,也会调用 playerStatusDidChange(status:) 方法并且参数值为 .completed。我们可以根据业务需要,来执行对应的操作。
//MARK: 播放器状态相关方法/// 播放状态改变/// - Parameter status: 播放状态func playerStatusDidChange(status: PHPlayerStatus) {if status == .playing {playButton.isSelected = true// 如果是暂停、完成、失败} else if status == .paused || status == .completed || status == .failed {playButton.isSelected = false} else if status == .completed {playCompleted()}}/// 播放完成private func playCompleted() {// 设置播放按钮为未选中状态playButton.isSelected = false// 设置当前时间为0currentTimeLabel.text = "00:00"// 设置slider为0slider.value = 0// 设置总时间为0delegate?.seekTo(time: 0)}
- 播放完成后首先修改按钮状态。
- 设置当前时间为00:00。
- 设置当前进度为0.0。
- 设置播放器回到0.0的位置。
结语
在本篇博客中,我们围绕 AVPlayer 播放控制 UI 的实现展开,介绍了如何构建播放画面的视图 PHPlayerView,以及如何在定义的 PHPlayerControlView 中实现播放/暂停按钮、进度条和时间显示等关键组件。
通过这些 UI 控件的封装,我们不仅提升了播放器的可交互性,也为后序更多功能扩展打下了基础。
下面的博客我们会进入 AVPlayer 的进阶功能,包含音轨、字幕、倍速等等。