引言
在之前的博客中,我们已经介绍了如何实现一个简单的播放器,并通过监听资源和播放器的属性来提升播放体验。因此本篇博客将带你进一步自定义播放器 UI。通过构建自己的播放控制界面(如播放/暂停按钮、进度条、全屏切换等),我们能够提供更符合需求的播放体验,并且支持用户通过手势操作进行更精细的控制。
播放画面的视图
在正式介绍“播放/暂停按钮”之前,我们先来看一下播放器的画面是如何展示在视图上的。
我们通过自定义的 PHPlayerView,使用 AVPlayerLayer 来承载 AVPlayer 的视频内容。通过覆写 layerClass 属性,让 PHPlayerView 的底层图层类型变为 AVPlayerLayer,从而直接承接播放画面。
import UIKit
import AVFoundation
class 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 Foundation
protocol PHPlayerProtocol:NSObjectProtocol {
/// 播放状态发生变化
/// - Parameter status: 播放状态
func playerStatusDidChange(status: PHPlayerStatus)
/// 播放进度
/// - Parameters:
/// - Parameter currentTime: 当前时间
/// - Parameter totalTime: 总时间
func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval)
}
PHControlProtocol
该协议为播放UI组件的代理协议,协议内定义了播放UI组件对播放控制器操作的方法,而播放控制器需要遵循该协议,并实现这些方法。目前协议内包含了播放、暂停、快进到指定时间三个方法。具体代码如下:
import Foundation
protocol 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 UIKit
class 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 in
make.leading.trailing.equalToSuperview()
make.top.equalToSuperview()
make.height.equalTo(MW_NAVIGATIONBAR_HEIGHT)
}
// 控制视图
controlView.snp.makeConstraints { make in
make.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 in
make.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时,我们可以进行灵活的自定义,比如圆点的颜色,圆点的图片。当前进度的颜色,以及默认的轨道颜色等等。
/// slider
let slider = UISlider()
// slider
self.addSubview(slider)
slider.minimumValue = 0
slider.maximumValue = 1
slider.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 }
...
// 设置slider
slider.minimumValue = 0
slider.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 = .white
currentTimeLabel.font = UIFont.systemFont(ofSize: 14)
currentTimeLabel.text = "00:00"
// 总时间
self.addSubview(totalTimeLabel)
totalTimeLabel.textColor = .white
totalTimeLabel.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 / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if 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
// 设置当前时间为0
currentTimeLabel.text = "00:00"
// 设置slider为0
slider.value = 0
// 设置总时间为0
delegate?.seekTo(time: 0)
}
- 播放完成后首先修改按钮状态。
- 设置当前时间为00:00。
- 设置当前进度为0.0。
- 设置播放器回到0.0的位置。
结语
在本篇博客中,我们围绕 AVPlayer 播放控制 UI 的实现展开,介绍了如何构建播放画面的视图 PHPlayerView,以及如何在定义的 PHPlayerControlView 中实现播放/暂停按钮、进度条和时间显示等关键组件。
通过这些 UI 控件的封装,我们不仅提升了播放器的可交互性,也为后序更多功能扩展打下了基础。
下面的博客我们会进入 AVPlayer 的进阶功能,包含音轨、字幕、倍速等等。