最近开发了一个较大的需求,即视频列表,特点是每个视频卡片高度不一致,包含不同的元素,若卡片长度超过一屏,还需要将底部的操作栏悬浮。可以上滑下滑自动切换到下一个播放.
整体实现
UITableView作为容器,每一个Item都是一个视频。卡片高度使用自动布局计算,宽度跟手机屏幕宽度相等。
踩到的坑
- 自定义UITableviewCell或者是UICollectionviewCell时,添加子view要加到contentview上,若直接加到cell的view上,则点击事件会失效。
func setUPUi() {
backgroundColor = UIColor.clear
self.selectionStyle = .none
let width = WowUtil.shared.getDeviceSize().0
<!--一定要使用contentview-->
self.contentView.addSubview(topBarContainer)
topBarContainer.snp.makeConstraints { make in
make.top.left.right.equalToSuperview()
make.height.equalTo(VideoDetailCellTopBar.topBarHeight)
}
}
- 约束一定要完整,第一个view的top和卡片的contentview的顶部对齐,最后一个view的bottom和contentview的bottom对齐。
- 如果卡片的某些View因为数据的不同而展示或者不展示,需要将View移除,同时将有约束关联的View的约束remake一次。
- 当使用reloaddata或者insert等collectionview相关操作时,或者页面旋转时,需要先设置下预估高度(contentoffset.y/rownumber),否则会出现列表跳动的情况。其根本原因是这些操作的前后,collectionview都是保证contentoffset不变,所以会根据contentoffset.y/estimateheight来决定要滚动到什么地方去。注意:estimatedHeight不能为负数,所以需要判断大于0再设置。
if portrait != self.isPortrait {
self.isPortrait = portrait
if let row = curHighLightIndexPath?.row , row > 0 {
if collectionView.contentOffset.y > 0 {
collectionView.estimatedRowHeight = collectionView.contentOffset.y / CGFloat(row)
}
}
}
- UItableview和UIcollectionview的随手指滚动到下一个or上一个的api实现方式:实现scrollViewWillEndDragging代理,根据滑动速度来判断是否需要吸顶,根据滑动方向来判断滑动到哪一个卡片。通过tableview.indexPathsForVisibleRows来获取当前页面的可见卡片,然后通过tableview.rectForRow(indexPath)来获取指定卡片在tableview中的位置,并把这个位置设置给targetContentOffset就能保证tableview滚动到指定位置了。
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
if abs(velocity.y) > changePageVelocityThreshold,let curVisibleIndexPaths = collectionView.indexPathsForVisibleRows { // 加速度超过阈值,才需要启动自动定位到上一页or下一页
if velocity.y > 0 {
// 手指从下往上滑
if curVisibleIndexPaths.count > 1 {
// 直接取第二个
// dedebugPrint("多个:\(screenRectItemLayoutAttrs[1].frame.origin.y)")
if let cell = collectionView.cellForRow(at: curVisibleIndexPaths[1]) {
targetContentOffset.pointee = cell.frame.origin
curHighLightIndexPath = curVisibleIndexPaths[1]
}
} else if curVisibleIndexPaths.count == 1 {
let curIndexPath = curVisibleIndexPaths.first!
// debugPrint("单个,indexCount:\(curIndexPath.count)")
let count = collectionView.numberOfRows(inSection: 0)
if curIndexPath.row < (count - 1) {
let nextIndexPath = IndexPath(row: curIndexPath.row + 1, section: curIndexPath.section)
let nextRect = collectionView.rectForRow(at: nextIndexPath)
targetContentOffset.pointee = nextRect.origin
curHighLightIndexPath = nextIndexPath
}
}
} else if velocity.y < 0 {
// 手指从上往下滑
if curVisibleIndexPaths.count > 1 {
let topCardRect = collectionView.rectForRow(at: curVisibleIndexPaths[0])
let topItemLayout = topCardRect.y
let topItemHeight = topCardRect.height
if collectionView.contentOffset.y > (topItemLayout + topItemHeight / 2) {
// 最顶部的卡片没有完全展示,并且未展示的部分超过卡片整体高度的1/2时,将最顶部卡片作为下一个展示的目标;否则将最顶部卡片的上一个卡片作为下一个展示的目标。
targetContentOffset.pointee = topCardRect.origin
self.curHighLightIndexPath = curVisibleIndexPaths[0]
// debugPrint("手指从上往下滑:if分支\(topItemLayout)")
} else {
let curIndexPath = curVisibleIndexPaths.first!
if curIndexPath.row > 0 {
let previousIndexPath = IndexPath(row: curIndexPath.row - 1, section: curIndexPath.section)
let previousCell = collectionView.rectForRow(at: previousIndexPath)
targetContentOffset.pointee = previousCell.origin
self.curHighLightIndexPath = previousIndexPath
//debugPrint("手指从上往下滑:else分支\(previousCell.origin)")
}
}
} else if curVisibleIndexPaths.count == 1 {
let curIndexPath = curVisibleIndexPaths.first!
if curIndexPath.row > 0 {
let previousIndexPath = IndexPath(row: curIndexPath.row - 1, section: curIndexPath.section)
let previousCell = collectionView.rectForRow(at: previousIndexPath)
targetContentOffset.pointee = previousCell.origin
self.curHighLightIndexPath = previousIndexPath
// debugPrint("手指从上往下滑:else分支\(previousCell.origin)")
}
}
}
} else {
calCurrentHighlightItem()
}
}
6、UItableview卡片复用。项目中是在卡片绑定数据的时候开始预加载视频,卡片只有在即将可见的时候才会绑定数据,如果当前卡片特别长,那么下一个卡片就不会提前加载视频数据,处理方式就是我们手动预加载下一个卡片(当检测到屏幕内只有一个卡片时),并保存到一个map中,map的key是indexpath,value就是构建的卡片,在tableview的cellforindexpath回调中,先判断缓存map中是否存在指定卡片,有的话直接取。
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
<!--优先从缓存map中取cell-->
if let cell = preBuildedCells[indexPath] {
debugPrint("prebuildCell:getCell.\(indexPath.row)")
preBuildedCells[indexPath] = nil
return cell
}
return buildCell(indexPath)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
prebuildCell(scrollView)
self.previousContentOffsetY = scrollView.contentOffset.y
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
prebuildCell(scrollView)
self.previousContentOffsetY = scrollView.contentOffset.y
}
}
<!--构建下一个Cell-->
func prebuildCell(_ scrollView: UIScrollView) {
if let visibleIndexPaths = collectionView.indexPathsForVisibleRows, visibleIndexPaths.count == 1 {
let count = collectionView.numberOfRows(inSection: 0)
let curRow = visibleIndexPaths.first!.row
let newContentOffsetY = scrollView.contentOffset.y
if newContentOffsetY >= previousContentOffsetY, curRow < (count - 1) {
let preBuildIndexPath = IndexPath(row: curRow + 1, section: 0)
// debugPrint("prebuildCell:\(curRow + 1)")
let cell = self.buildCell(preBuildIndexPath)
<!--放到缓存map中-->
preBuildedCells[preBuildIndexPath] = cell
} else if newContentOffsetY < previousContentOffsetY, curRow > 0 {
let preBuildIndexPath = IndexPath(row: curRow - 1, section: 0)
// debugPrint("prebuildCell:\(curRow - 1)")
let cell = self.buildCell(preBuildIndexPath)
preBuildedCells[preBuildIndexPath] = cell
}
}
}
7、Uitableview的卡片的某个子View存在吸顶or吸底操作,实现方式:实现UIScrollview的滚动代理scrollViewDidScroll,在其中不断得检测当前屏幕内的可见卡片数量,并执行如下逻辑。
func checkBtmBarFloatStatus() {
let visibleCells = self.collectionView.visibleCells
let cell = visibleCells.first
if visibleCells.count == 1 && cell.frame.origin.y + cell.frame.height - contentOffset > collectionView.frame.height {
<!-- 需要悬浮底部栏 -->
} else if visibleCells.count == 2 {
let firstCell = visibleCells.first
let secondCell = visibleCells[1]
if firstCell只露出了一点点&&secondCell.height>collectionView.height {
<!-- 需要悬浮底部栏 -->
} else {
<!-- 恢复底部栏 -->
}
} else {
<!-- 恢复底部栏 -->
}
}
func 悬浮底部栏() {
/// 解决方案:需要悬浮底部栏时,把btmbar从当前cell中抠出来放到ViewController的根View上;不需要悬浮底部栏时,把btmbar加回到cell上。这里面还有个问题,因为btmbar的背景是透明的,当把btmbar放到根view上后,导致会露出底部UITableView的内容,解决方案是利用UIView的clipbounds属性,将UITableView加到一个容器View中,这个容器的高度为屏幕高度-底部栏高度,通过动态改变容器View的clipbounds属性,实现对UITableview的底部栏内容显示和不显示来解决问题。
}
8、视频预加载操作。iOS的系统apiAVPlayer有一个preroll方法就是用来预加载的,预加载的效果非常明显。未启动预加载时,开始加载->开始播放耗时1s,启用预加载后,耗时变为0.5s.
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard context == &observerContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
if keyPath == "status" {
if let status = change?[.newKey] as? Int, status == AVPlayer.Status.readyToPlay.rawValue, YDVideoPlayer.openPreroll, player.rate == 0, self.prerollUrl != self.url {
self.prerollUrl = self.url
player.preroll(atRate: 1) { result in
debugPrint("YDVideoPlayer: preroll result url:\(self.url?.lastPathComponent)")
}
}
}
updateStatus()
}
9、代码的整体架构设计。
卡片的子view很多,并且各个子view的交互比较复杂,可以抽象一个baseview来用模板模式来简化代码,并且把model数据直接设置给View,不要想着只塞入该View需要的字段,因为后续log上报或者一些其他交互行为很有可能依赖model本身。如下:
open class VideoDetailBaseView: UIView {
private(set) var model:WowDetailModel?
init() {
super.init(frame:.zero)
setUpUI()
}
required public init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 生成Cell的时候调用
func setUpUI() {
}
/// 更新cell数据的时候调用
func updateUI() {
}
/// 更新model时重置一些状态
func resetState() {
}
func setData(_ model:WowDetailModel?,updateUI:Bool = false) {
self.model = model
self.resetState()
if updateUI {
self.updateUI()
}
}
func startHighLight(){
}
func stopHighLight(){
}
}
10、视频播放的问题。AvPlayer播放是耗时的,不要直接在主线程中使用,需要将播放器的操作已经相关状态更新全部集中到一个队列中,保证状态的一致。